Compare commits

...
31 changed files with 2652 additions and 923 deletions
Generated
+3 -1
View File
@@ -270,9 +270,11 @@ name = "wip-protocol"
version = "0.1.0" version = "0.1.0"
[[package]] [[package]]
name = "wip-rs" name = "wip-public-api-example"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"wip-host",
"wip-interface",
"wip-protocol", "wip-protocol",
] ]
+1 -15
View File
@@ -1,17 +1,3 @@
[workspace] [workspace]
members = [ members = ["crates/*"]
".",
"crates/wip-host",
"crates/wip-interface",
"crates/wip-interface-macros",
"crates/wip-protocol",
]
resolver = "3" resolver = "3"
[package]
name = "wip-rs"
version = "0.1.0"
edition = "2024"
[dependencies]
wip-protocol = { path = "crates/wip-protocol" }
+177 -30
View File
@@ -2,6 +2,82 @@
Rust building blocks for the Web Interface Protocol (WIP). Rust building blocks for the Web Interface Protocol (WIP).
The repository is a resolver-3 virtual Cargo workspace whose packages all live
under `crates/*`. It provides a transport-independent protocol model, a Rust
interface-authoring facade, a request-scoped host runtime, and a private
public-API conformance consumer. The repository root is not itself a package.
HTTP and other transport bindings are intentionally outside the supported
slice.
## Crate graph and responsibilities
```text
wip-public-api-example (workspace-only executable, integration tests, doctest)
├── wip-protocol logical declarations, values, calls, and validation
├── wip-interface application DTO codecs and interface authoring facade
│ ├── wip-protocol
│ └── wip-interface-macros proc-macro implementation detail
└── wip-host descriptor registry, publication, and qualified dispatch
├── wip-interface
└── wip-protocol
```
| Crate | Responsibility | Does not own |
| --- | --- | --- |
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, and interface-qualified dispatch against one host-owned instance. | Backend atomicity, global object lookup, or network serving. |
| `wip-public-api-example` | An independently compiled consumer of only the three public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
The boundary is intentional: protocol types describe what can cross a
transport; application DTOs opt into that model with `WipType`; a host-private
implementation owns credentials, handles, and backend state; and a future
transport maps its own routes/envelopes to `CallOperationRequest` and
`CallOperationResponse` without placing transport-specific types in descriptors
or application traits.
## End-to-end example
[`crates/wip-public-api-example/src/lib.rs`](crates/wip-public-api-example/src/lib.rs)
is the complete public-API example and
[`crates/wip-public-api-example/src/main.rs`](crates/wip-public-api-example/src/main.rs)
runs it. It demonstrates all of the following:
1. public application DTOs derived as `WipType` records, enums, and unions,
including canonical entry paths and schema-opaque JSON;
2. two public traits declared with `#[wip::interface]`;
3. a private host struct implementing both traits;
4. explicit publication of both interfaces as an ordered set on one object;
5. separate observation of opaque interface references, descriptors, and
descriptor validators;
6. calls qualified by `target.path`, object validator, interface reference and
validator, operation, and schema-neutral `Value` arguments;
7. validated result values decoded back to the typed application result;
8. independent dispatch of the same `status` operation name on both interfaces;
9. distinct domain rejection, host/backend failure, `PermissionDenied`, stale
validator recovery, interface membership, and lookup failure codes; and
10. canonical failure precedence when several request fields are invalid.
Run it inside the development shell:
```console
nix develop
cargo run --package wip-public-api-example
```
The public-only conformance suite is in
[`crates/wip-public-api-example/tests/end_to_end.rs`](crates/wip-public-api-example/tests/end_to_end.rs).
It covers canonical object/descriptor shape, ordered interfaces,
schema-neutral record/enum/union/entry/JSON values, fully qualified calls, and
the distinct domain, host, authorization, validator-recovery, and interface
failure classes. Exhaustive public type construction and matching make legacy
ObjectId, descriptor `name`, and semantic `Value` variants compile-time
regressions. Unit tests in the public crates additionally cover every host
dispatch failure, `InvalidRequest` versus descriptor-bound `InvalidArguments`,
invalid local responses, validators, same-named operations, and exhaustive
per-interaction protocol error-code sets.
## Interface authoring ## Interface authoring
Applications depend on `wip-interface`, normally imported as `wip`: Applications depend on `wip-interface`, normally imported as `wip`:
@@ -30,42 +106,69 @@ pub trait Messenger {
``` ```
The `interface` attribute and `WipType` derive are re-exported by the facade; The `interface` attribute and `WipType` derive are re-exported by the facade;
users do not import `wip-interface-macros` directly. The generated users do not import `wip-interface-macros` or generated private support symbols.
`MessengerDefinition` constructs an `InterfaceDescriptor`, decodes named The generated `MessengerDefinition` constructs an `InterfaceDescriptor`,
arguments, encodes results, validates both boundaries, and provides typed decodes named arguments, encodes results, validates both boundaries, and
dispatch for any `T: Messenger`. provides typed dispatch for any `T: Messenger`.
### Type mapping ### Supported Rust syntax
A supported interface is a public, non-generic trait containing methods with:
- exactly an immutable `&self` receiver;
- simple named, owned parameters;
- an explicit `wip::OperationResult<T>` return;
- `()` only as the application return when no value is produced; and
- direct `Option<T>` only for an omittable parameter.
`WipType` derives support named-field structs, all-unit enums, and unions whose
cases may be unit variants or variants containing exactly one unnamed payload.
The macros reject private,
generic, inherited, unsafe, auto, or associated-item traits; const, async,
unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods;
borrowed/custom-lifetime values; tuple/unit/generic structs; Rust unions;
named-field/multi-field enum variants; explicit discriminants; nested `Option`; and
`Option` in returns, collections, entries, or union payloads. These diagnostics
are compile-fail tested under `crates/wip-interface/tests/ui`.
### Rust type mapping
| Rust | Protocol | | Rust | Protocol |
| --- | --- | | --- | --- |
| `()` as an operation result | no returned value | | `()` | unit / `Value::Unit` |
| `bool` | boolean | | `bool` | boolean |
| integer primitives | JavaScript-lossless integer | | integer primitives | JavaScript-lossless integer |
| `f32`, `f64` | finite number | | `f32`, `f64` | finite number |
| `String` | string | | `String` | string |
| `wip::Bytes` | bytes | | `wip::Bytes` | bytes |
| `wip::wip_protocol::JsonValue` | JSON | | `wip::JsonValue` | JSON interpreted over the schema-neutral `Value` tree |
| `wip::Entry<K, V>` | entry | | `wip::Entry` | canonical absolute Worldspace path encoded as a string |
| `Vec<T>` | list | | `Vec<T>` | list |
| named-field `#[derive(wip::WipType)]` struct | named record | | named-field `#[derive(wip::WipType)]` struct | named record |
| all-unit `#[derive(wip::WipType)]` enum | named enum | | all-unit `#[derive(wip::WipType)]` enum | named enum encoded as a string |
| all-newtype `#[derive(wip::WipType)]` enum | named tagged union | | mixed unit/newtype or all-newtype enum | named union encoded as a discriminator record |
`Option<T>` is accepted only for a direct DTO field or operation parameter and `Option<T>` means that a direct DTO field or operation parameter may be
means that the named value may be omitted. It is not protocol null. Domain omitted; it is not protocol null. JSON null remains available inside
failures are modeled in the application result (usually a derived union) and `wip::JsonValue`.
returned through `Ok`; `wip::HostError` is only for host/runtime failures.
#### Migration from the pre-canonical value API
The old generic `wip::Entry<K, V>` represented an application key/value pair
and is no longer available. Use `wip::Entry::new("/canonical/path")` for a
Worldspace entry path and place any associated application data in a record.
`Entry` encodes as `Value::String` and validates the canonical absolute path.
The semantic `Value::Json`, `Value::Entry`, `Value::Enum`, and `Value::Union`
variants have also been removed. JSON recursively uses `Unit`, primitive,
`List`, and `Record` values and rejects `Bytes`; enums use `String`; unions use
a `Record` containing `$case` and, for payload-bearing cases, `value`. Unit
operation results are represented explicitly as `Value::Unit`.
Rust doc comments become descriptor metadata. For an operation, a `# Returns` Rust doc comments become descriptor metadata. For an operation, a `# Returns`
section documents the return declaration; text before that section documents section documents the return declaration; text before that section documents
the operation itself. the operation itself.
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 ## Host publication and dispatch
Applications use `wip-host` to register generated definitions and explicitly Applications use `wip-host` to register generated definitions and explicitly
@@ -83,17 +186,61 @@ let protocol_object = published.protocol_object();
`expose` compiles only when the object implements the generated definition's `expose` compiles only when the object implements the generated definition's
interface trait. Implementing a trait does not expose it automatically, empty interface trait. Implementing a trait does not expose it automatically, empty
interface sets are valid, insertion order is preserved, and duplicate interface sets are valid, insertion order is preserved, and duplicate
references are rejected. Registry references are opaque; descriptor names and references are rejected. Registry references are opaque and remain outside the
Rust type names are not used as wire identity. descriptor; generated descriptors contain neither those references nor Rust
trait identity.
Dispatch is qualified by interface reference and operation name. The host checks Dispatch is qualified by interface reference and operation name and targets a
current object identity and validator policy, current interface membership, canonical Worldspace path. An `ObjectProvider` validates that path before
interface validator, descriptor operation, arguments, and result while retaining resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
the selected object and registered artifact. Host-private fields and backend exactly once inside `with_object`; the host checks the supplied object validator,
handles are never projected by `WipObject`. current interface membership, interface validator, descriptor operation,
operation-specific object-validator policy, arguments, and authorization in
canonical precedence. Path and children remain provider resolution context and
are never projected by `WipObject`; host-private fields and backend handles are
also excluded.
Application domain failures belong in a derived result union and are returned
through `Ok`. `wip::HostError` is reserved for runtime/backend failures. A
structurally malformed call is `InvalidRequest`; only argument values checked
after the current Interface Descriptor is fixed are `InvalidArguments`. A Host
must never return a wire `InvalidResult`: Clients keep invalid Object,
Descriptor, operation-result, and error responses as local `InvalidResponse`
failures, while unsupported descriptor formats remain local
`UnsupportedDescriptorFormat` failures.
For operation calls, `Internal` and `ResourceLimitExceeded` guarantee that the
operation did not run or that its effects were rolled back.
`OperationOutcomeUnknown` is the only protocol code that makes no such
guarantee, so Clients must not automatically retry it. The current host API maps
implementation failures and invalid post-execution results conservatively to
`OperationOutcomeUnknown`.
Providers remain responsible for backend atomicity. Enter a transaction, lock, Providers remain responsible for backend atomicity. Enter a transaction, lock,
stable-handle scope, or conditional-update primitive before constructing and stable-handle scope, or conditional-update primitive before invoking the
dispatching the request-scoped `PublishedObject`, and retain it until dispatch `with_object` callback, and retain it until the callback returns. Mutable
returns. The runtime never validates one object and then resolves a second operations must make validator comparison and backend commit atomic and return a
snapshot, but it cannot make an arbitrary external backend atomic by itself. validator for the committed state. The runtime never validates one object and
then resolves a second snapshot, but it cannot make an arbitrary external
backend atomic by itself.
## Validation
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 test --workspace --doc
cargo clippy --workspace --all-targets -- -D warnings
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps
cargo run --package wip-public-api-example
git diff --check
```
`cargo test --workspace --all-targets` includes the public-only conformance
consumer, runtime failure tests, and `trybuild` compile-fail suites. The separate
doctest, rustdoc, executable, and whitespace checks are all required acceptance
steps.
+250 -161
View File
@@ -3,35 +3,31 @@
//! # Responsibility boundaries //! # Responsibility boundaries
//! //!
//! [`InterfaceRegistry::register`] validates and freezes a generated interface //! [`InterfaceRegistry::register`] validates and freezes a generated interface
//! descriptor, assigns an opaque [`InterfaceRef`], and makes the same descriptor //! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference
//! observation available to clients and dispatch. [`PublishedObject::expose`] //! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
//! explicitly combines a request-scoped Rust object with a registered interface; //! request-scoped Rust object with a registered interface; its
//! its [`InterfaceImplementation`] bound proves at compile time that the object //! [`InterfaceImplementation`] bound proves at compile time that the object
//! implements that interface. Implementing a Rust trait alone never publishes it, //! implements the interface. Implementing a Rust trait alone never publishes it,
//! and a publication with no interfaces is valid. //! and a publication with no interfaces is valid.
//! //!
//! [`PublishedObject::dispatch`] selects by `(InterfaceRef, operation name)`, //! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
//! checks object and interface validators, current membership, the frozen //! keeps the resulting object, interface membership, descriptor, implementation,
//! descriptor, and then invokes the matching generated typed dispatcher on the //! and backend target fixed within the provider's [`ObjectProvider::with_object`]
//! same object instance. Arguments and results cross the protocol [`Value`] //! callback. Dispatch is qualified by `(interface reference, operation name)`.
//! boundary only through generated validation and codecs. Rust fields that are //! The same frozen operation declaration validates both arguments and results.
//! not returned by [`WipObject`] are host-private and are never serialized by
//! this crate.
//! //!
//! The call method is deliberately an execution boundary rather than a global //! A provider is responsible for entering a backend transaction, lock,
//! object registry: a provider may resolve and publish an object per request and //! stable-handle scope, or conditional-update primitive before invoking the
//! drop it afterward. The provider must enter any backend-specific transaction, //! callback and retaining it until the callback returns. The runtime does not
//! lock, stable-handle scope, or conditional-update primitive *before* creating //! reopen the path or select another implementation after checking validators,
//! or dispatching the publication and retain it until dispatch returns. This //! but cannot make an arbitrary external backend atomic on its own. Mutable
//! crate keeps validator checks, descriptor and implementation selection, and //! operations must make object-validator comparison and backend commit atomic;
//! invocation in one borrow of that publication; it does not claim that an //! the validator returned after dispatch must describe the committed state.
//! arbitrary backend stays stable, and it never reopens a path or resolves a
//! second implementation snapshot after validation.
#![deny(missing_docs)] #![deny(missing_docs)]
use std::any::TypeId; use std::any::TypeId;
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::error::Error; use std::error::Error;
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
use std::marker::PhantomData; use std::marker::PhantomData;
@@ -41,9 +37,13 @@ use std::sync::atomic::{AtomicU64, Ordering};
use wip_interface::DispatchError; use wip_interface::DispatchError;
pub use wip_interface::{InterfaceDefinition, InterfaceImplementation}; pub use wip_interface::{InterfaceDefinition, InterfaceImplementation};
pub use wip_protocol; pub use wip_protocol;
pub use wip_protocol::{
CallOperationRequest as CallRequest, CallOperationResponse as CallResult, InterfaceTarget,
Target,
};
use wip_protocol::{ use wip_protocol::{
InterfaceDescriptor, NamedValue, Object, ObjectId, OperationCall, OperationTarget, FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
ProtocolError, ProtocolErrorCategory, ValidationError, Value, ValidationError, Value,
}; };
/// Opaque bytes used as a conditional execution precondition. /// Opaque bytes used as a conditional execution precondition.
@@ -76,22 +76,34 @@ impl InterfaceRef {
} }
} }
/// An immutable observation returned for an opaque interface reference. /// One atomic observation of an interface reference, descriptor, and validator.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct InterfaceObservation { pub struct InterfaceObservation {
/// Opaque reference selected by published objects and operation calls. /// Opaque reference selected by published objects and operation calls.
pub reference: InterfaceRef, pub reference: InterfaceRef,
/// Canonical descriptor frozen when the interface was registered.
pub descriptor: InterfaceDescriptor,
/// Optional validator. Its presence makes it a required call precondition. /// Optional validator. Its presence makes it a required call precondition.
pub validator: Option<Validator>, pub validator: Option<Validator>,
/// Validated descriptor frozen when the interface was registered. }
pub descriptor: InterfaceDescriptor,
impl InterfaceObservation {
/// Converts this host observation to the canonical protocol response.
#[must_use]
pub fn protocol_response(&self) -> FetchInterfaceResponse {
FetchInterfaceResponse {
interface: self.reference.0.clone(),
descriptor: self.descriptor.clone(),
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
}
}
} }
#[derive(Debug)] #[derive(Debug)]
struct InterfaceArtifact { struct InterfaceArtifact {
reference: InterfaceRef, reference: InterfaceRef,
validator: Option<Validator>,
descriptor: InterfaceDescriptor, descriptor: InterfaceDescriptor,
validator: Option<Validator>,
} }
/// A typed registration token for a generated interface definition. /// A typed registration token for a generated interface definition.
@@ -122,12 +134,11 @@ impl<D> RegisteredInterface<D> {
/// Registry of validated, immutable interface artifacts. /// Registry of validated, immutable interface artifacts.
/// ///
/// This registry stores interface definitions, not object instances. Generated /// Generated descriptors are never rewritten to contain their assigned
/// definitions are registered explicitly and receive process-local opaque
/// references. A validator-free artifact is immutable for the lifetime of its /// references. A validator-free artifact is immutable for the lifetime of its
/// reference; a validator-bearing artifact requires that exact validator on a /// reference. A validator-bearing artifact requires that exact validator on a
/// call. Re-registering a definition is rejected instead of silently changing /// call. Re-registering a definition is rejected rather than silently changing
/// the descriptor associated with an existing reference. /// the observation associated with an existing reference.
#[derive(Debug)] #[derive(Debug)]
pub struct InterfaceRegistry { pub struct InterfaceRegistry {
namespace: u64, namespace: u64,
@@ -157,12 +168,6 @@ impl InterfaceRegistry {
} }
/// Registers and freezes one generated interface definition. /// Registers and freezes one generated interface definition.
///
/// `validator` should be present when the host intends the reference to
/// represent a versioned descriptor. Its presence makes the same value a
/// mandatory precondition for dispatch. Registration finalizes the
/// descriptor's protocol `name` to the assigned opaque reference so it
/// corresponds directly to [`Object::interfaces`].
pub fn register<D>( pub fn register<D>(
&mut self, &mut self,
_definition: D, _definition: D,
@@ -176,7 +181,7 @@ impl InterfaceRegistry {
return Err(RegistrationError::AlreadyRegistered); return Err(RegistrationError::AlreadyRegistered);
} }
let mut descriptor = D::descriptor(); let descriptor = D::descriptor();
descriptor descriptor
.validate() .validate()
.map_err(RegistrationError::InvalidDescriptor)?; .map_err(RegistrationError::InvalidDescriptor)?;
@@ -185,19 +190,14 @@ impl InterfaceRegistry {
"wip-interface:{}:{}", "wip-interface:{}:{}",
self.namespace, self.next_reference self.namespace, self.next_reference
)); ));
// The protocol model currently carries the interface identifier in the
// descriptor's `name` field. Finalize that identity to the same opaque
// reference placed on published objects; the generated Rust trait name
// is registration-local metadata, not wire identity.
descriptor.name = reference.0.clone();
self.next_reference = self self.next_reference = self
.next_reference .next_reference
.checked_add(1) .checked_add(1)
.ok_or(RegistrationError::ReferenceSpaceExhausted)?; .ok_or(RegistrationError::ReferenceSpaceExhausted)?;
let artifact = Arc::new(InterfaceArtifact { let artifact = Arc::new(InterfaceArtifact {
reference: reference.clone(), reference: reference.clone(),
validator,
descriptor, descriptor,
validator,
}); });
self.definitions.insert(definition); self.definitions.insert(definition);
self.artifacts.insert(reference, Arc::clone(&artifact)); self.artifacts.insert(reference, Arc::clone(&artifact));
@@ -208,17 +208,41 @@ impl InterfaceRegistry {
}) })
} }
/// Observes the descriptor and optional validator for an opaque reference. /// Resolves a transport-provided opaque reference string in this registry.
#[must_use]
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
let reference = InterfaceRef(reference.to_owned());
self.artifacts.contains_key(&reference).then_some(reference)
}
/// Observes a registered interface by its host-side reference.
#[must_use] #[must_use]
pub fn observe(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> { pub fn observe(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
self.artifacts.get(reference).map(Self::observation)
}
/// Implements canonical `fetch_interface` semantics for an opaque reference.
///
/// Unknown and currently unpublished references map to `InterfaceNotFound`,
/// not object membership errors or authorization failures.
pub fn fetch_interface(&self, reference: &str) -> Result<InterfaceObservation, ProtocolError> {
let reference = InterfaceRef(reference.to_owned());
self.artifacts self.artifacts
.get(reference) .get(&reference)
.map(|artifact| InterfaceObservation { .map(Self::observation)
reference: artifact.reference.clone(), .ok_or_else(|| ProtocolError {
validator: artifact.validator.clone(), code: ProtocolErrorCode::InterfaceNotFound,
descriptor: artifact.descriptor.clone(), message: "interface reference is not currently published".into(),
}) })
} }
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation {
InterfaceObservation {
reference: artifact.reference.clone(),
descriptor: artifact.descriptor.clone(),
validator: artifact.validator.clone(),
}
}
} }
/// Failure while registering a generated interface definition. /// Failure while registering a generated interface definition.
@@ -260,24 +284,37 @@ impl Error for RegistrationError {
/// Public metadata and conditional-execution policy for a host-private object. /// Public metadata and conditional-execution policy for a host-private object.
/// ///
/// Only values returned by this trait are projected into protocol metadata. /// Only values returned by this trait are projected into protocol metadata.
/// Backend handles, filesystem paths, credentials, and other private fields on /// Paths, children, backend handles, credentials, and resolution state are not
/// the implementing type are neither inspected nor serialized. /// part of this API and therefore cannot be serialized into [`Object`].
pub trait WipObject { pub trait WipObject {
/// Returns the current logical identity of this object. /// Returns the public path-segment name of this object.
fn id(&self) -> ObjectId; fn name(&self) -> &str;
/// Returns a short public description, if one is available.
fn description(&self) -> Option<&str> {
None
}
/// Returns optional opaque identity within the client-facing Worldspace.
fn reference(&self) -> Option<&str> {
None
}
/// Returns the current opaque object validator, if any. /// Returns the current opaque object validator, if any.
fn validator(&self) -> Option<&Validator> { fn validator(&self) -> Option<&Validator> {
None None
} }
/// Whether a call must include the current object validator. /// Whether this specific qualified operation requires an object validator.
fn requires_validator(&self) -> bool { ///
/// This hook is evaluated only after membership, interface validators, and
/// operation existence have been checked, preserving canonical precedence.
fn requires_validator(&self, _interface: &InterfaceRef, _operation: &str) -> bool {
false false
} }
} }
type DispatchFn<T> = fn(&T, &str, Vec<NamedValue>) -> Result<Option<Value>, DispatchError>; type DispatchFn<T> = fn(&T, &str, BTreeMap<String, Value>) -> Result<Value, DispatchError>;
struct BoundInterface<T> { struct BoundInterface<T> {
artifact: Arc<InterfaceArtifact>, artifact: Arc<InterfaceArtifact>,
@@ -287,7 +324,8 @@ struct BoundInterface<T> {
/// A host-private object instance combined with an explicit ordered interface set. /// A host-private object instance combined with an explicit ordered interface set.
/// ///
/// The instance is owned by this value and can therefore be request-scoped. No /// The instance is owned by this value and can therefore be request-scoped. No
/// global or pre-populated object registry is required. /// global object registry is required. Worldspace paths remain provider context
/// and are deliberately absent from the publication.
pub struct PublishedObject<T: WipObject> { pub struct PublishedObject<T: WipObject> {
implementation: T, implementation: T,
interfaces: Vec<BoundInterface<T>>, interfaces: Vec<BoundInterface<T>>,
@@ -335,56 +373,46 @@ impl<T: WipObject> PublishedObject<T> {
&self.implementation &self.implementation
} }
/// Builds the protocol object metadata with interface insertion order intact. /// Builds canonical protocol object metadata in interface insertion order.
///
/// The observation contains no path, children, descriptor, backend handle,
/// credential, or provider resolution state.
#[must_use] #[must_use]
pub fn protocol_object(&self) -> Object { pub fn protocol_object(&self) -> Object {
let object = Object { Object {
id: self.implementation.id(), name: self.implementation.name().to_owned(),
description: self.implementation.description().map(str::to_owned),
interfaces: self interfaces: self
.interfaces .interfaces
.iter() .iter()
.map(|bound| bound.artifact.reference.0.clone()) .map(|bound| bound.artifact.reference.0.clone())
.collect(), .collect(),
}; r#ref: self.implementation.reference().map(str::to_owned),
debug_assert!(object.validate().is_ok()); validator: self
object .implementation
.validator()
.map(|validator| validator.0.clone()),
} }
/// Dispatches one qualified operation against this current publication.
///
/// Preconditions are checked before generated operation code runs. The
/// selected artifact and function pointer are borrowed once and are not
/// resolved again after validator checks.
pub fn dispatch(&self, request: CallRequest) -> Result<CallResult, CallError> {
if request.object != self.implementation.id() {
return Err(CallError::new(
CallErrorKind::ObjectNotFound,
"call target does not identify this published object",
));
} }
fn dispatch_resolved(
&self,
request: CallRequest,
authorize: impl FnOnce(&T, &InterfaceRef, &str) -> bool,
) -> Result<CallResult, CallError> {
let current_object_validator = self.implementation.validator(); let current_object_validator = self.implementation.validator();
match request.object_validator.as_ref() { if let Some(supplied) = request.target.validator.as_deref()
Some(supplied) if Some(supplied) != current_object_validator => { && current_object_validator.map(Validator::as_bytes) != Some(supplied)
{
return Err(CallError::new( return Err(CallError::new(
CallErrorKind::ValidatorMismatch, CallErrorKind::ValidatorMismatch,
"object validator does not match current object state", "object validator does not match current object state",
)); ));
} }
None if self.implementation.requires_validator() => {
return Err(CallError::new(
CallErrorKind::ValidatorRequired,
"this object requires a validator",
));
}
_ => {}
}
let Some(bound) = self let Some(bound) = self.interfaces.iter().find(|bound| {
.interfaces bound.artifact.reference.as_str() == request.interface.reference.as_str()
.iter() }) else {
.find(|bound| bound.artifact.reference == request.interface.reference)
else {
return Err(CallError::new( return Err(CallError::new(
CallErrorKind::InterfaceMismatch, CallErrorKind::InterfaceMismatch,
"interface is not a member of the current object publication", "interface is not a member of the current object publication",
@@ -393,7 +421,7 @@ impl<T: WipObject> PublishedObject<T> {
match ( match (
bound.artifact.validator.as_ref(), bound.artifact.validator.as_ref(),
request.interface.validator.as_ref(), request.interface.validator.as_deref(),
) { ) {
(Some(_), None) => { (Some(_), None) => {
return Err(CallError::new( return Err(CallError::new(
@@ -401,7 +429,7 @@ impl<T: WipObject> PublishedObject<T> {
"registered interface requires a validator", "registered interface requires a validator",
)); ));
} }
(expected, Some(actual)) if expected != Some(actual) => { (expected, Some(actual)) if expected.map(Validator::as_bytes) != Some(actual) => {
return Err(CallError::new( return Err(CallError::new(
CallErrorKind::InterfaceValidatorMismatch, CallErrorKind::InterfaceValidatorMismatch,
"interface validator does not match the registered descriptor", "interface validator does not match the registered descriptor",
@@ -423,31 +451,49 @@ impl<T: WipObject> PublishedObject<T> {
)); ));
} }
if request.target.validator.is_none()
&& self
.implementation
.requires_validator(&bound.artifact.reference, &request.operation)
{
return Err(CallError::new(
CallErrorKind::ValidatorRequired,
"this operation requires an object validator",
));
}
bound bound
.artifact .artifact
.descriptor .descriptor
.validate_call(&OperationCall { .validate_arguments(&request.operation, &request.arguments)
target: OperationTarget {
object: request.object.clone(),
interface: bound.artifact.descriptor.name.clone(),
operation: request.operation.clone(),
},
arguments: request.arguments.clone(),
})
.map_err(|error| CallError::new(CallErrorKind::InvalidArguments, error.to_string()))?; .map_err(|error| CallError::new(CallErrorKind::InvalidArguments, error.to_string()))?;
if !authorize(
&self.implementation,
&bound.artifact.reference,
&request.operation,
) {
return Err(CallError::new(
CallErrorKind::PermissionDenied,
"operation is not permitted for this caller",
));
}
let value = (bound.dispatch)(&self.implementation, &request.operation, request.arguments) let value = (bound.dispatch)(&self.implementation, &request.operation, request.arguments)
.map_err(CallError::from_dispatch)?; .map_err(CallError::from_dispatch)?;
bound bound
.artifact .artifact
.descriptor .descriptor
.validate_return(&request.operation, value.as_ref()) .validate_result(&request.operation, &value)
.map_err(|error| CallError::new(CallErrorKind::InvalidResult, error.to_string()))?; .map_err(|error| CallError::new(CallErrorKind::InvalidResult, error.to_string()))?;
Ok(CallResult { Ok(CallResult {
value, result: value,
validator: self.implementation.validator().cloned(), validator: self
.implementation
.validator()
.map(|validator| validator.0.clone()),
}) })
} }
@@ -458,6 +504,75 @@ impl<T: WipObject> PublishedObject<T> {
} }
} }
/// A request-scoped Worldspace resolver and execution boundary.
///
/// `with_object` must resolve the current object at `path` exactly once and pass
/// a publication that pins its current interface set, implementations, and
/// backend target for the duration of `operation`. Implementations may hold a
/// transaction or lock around the callback. Returning `None` means the path is
/// not published to the caller. The borrowed publication cannot escape the
/// callback, so the default call path cannot outlive that provider boundary.
pub trait ObjectProvider {
/// Host-private object type produced by this provider.
type Object: WipObject;
/// Runs an operation against the single object snapshot resolved at `path`.
fn with_object<R>(
&self,
path: &str,
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
) -> Option<R>;
/// Decides whether a declared operation may execute for the current caller.
///
/// The hook runs after all validators, membership, operation lookup, and
/// argument validation, but before implementation code. Returning `false`
/// exposes the operation while producing `PermissionDenied`.
fn authorize(
&self,
_path: &str,
_object: &Self::Object,
_interface: &InterfaceRef,
_operation: &str,
) -> bool {
true
}
/// Fetches the canonical object projection currently published at `path`.
fn fetch(&self, path: &str) -> Result<Object, CallError> {
wip_protocol::validate_path(path)
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
self.with_object(path, PublishedObject::protocol_object)
.ok_or_else(|| CallError::new(CallErrorKind::NotFound, "target path is not published"))
.and_then(|object| {
object
.validate_at_path(path)
.map_err(|error| CallError::new(CallErrorKind::Internal, error.to_string()))?;
Ok(object)
})
}
/// Resolves and executes a canonical path-targeted operation request.
///
/// Structural path validation precedes provider resolution. For a valid
/// request, failure checks follow the exact canonical precedence: path,
/// supplied object validator mismatch, membership, interface validator
/// requirement and mismatch, operation existence, object validator
/// requirement, arguments, then authorization. No failed precondition
/// invokes the provider callback or typed implementation.
fn call_operation(&self, request: CallRequest) -> Result<CallResult, CallError> {
wip_protocol::validate_path(&request.target.path)
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
let path = request.target.path.clone();
self.with_object(&path, |publication| {
publication.dispatch_resolved(request, |object, interface, operation| {
self.authorize(&path, object, interface, operation)
})
})
.ok_or_else(|| CallError::new(CallErrorKind::NotFound, "target path is not published"))?
}
}
/// Failure to construct an explicit object publication. /// Failure to construct an explicit object publication.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublicationError { pub enum PublicationError {
@@ -479,53 +594,24 @@ impl Display for PublicationError {
impl Error for PublicationError {} impl Error for PublicationError {}
/// Interface portion of a qualified operation target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceTarget {
/// Opaque reference selected from the current object's interface set.
pub reference: InterfaceRef,
/// Validator copied from the descriptor observation, when one was present.
pub validator: Option<Validator>,
}
/// One transport-independent request to a published object operation.
#[derive(Debug, Clone, PartialEq)]
pub struct CallRequest {
/// Current logical object identity.
pub object: ObjectId,
/// Optional current object-state precondition.
pub object_validator: Option<Validator>,
/// Qualified interface reference and descriptor precondition.
pub interface: InterfaceTarget,
/// Operation name within the selected interface descriptor.
pub operation: String,
/// Named protocol arguments.
pub arguments: Vec<NamedValue>,
}
/// Successful host dispatch result.
#[derive(Debug, Clone, PartialEq)]
pub struct CallResult {
/// Encoded application value, or `None` for a no-return operation.
pub value: Option<Value>,
/// Object validator observed after the implementation returned.
pub validator: Option<Validator>,
}
/// Stable host dispatch failure kind. /// Stable host dispatch failure kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallErrorKind { pub enum CallErrorKind {
/// The call names a different or unavailable object. /// The request is structurally invalid.
ObjectNotFound, InvalidRequest,
/// The target path is not currently published.
NotFound,
/// The selected interface is not exposed by the current object. /// The selected interface is not exposed by the current object.
InterfaceMismatch, InterfaceMismatch,
/// The operation is absent from the selected descriptor. /// The operation is absent from the selected descriptor.
OperationNotFound, OperationNotFound,
/// Supplied named values do not satisfy the operation declaration. /// Supplied named values do not satisfy the operation declaration.
InvalidArguments, InvalidArguments,
/// The encoded implementation result violates its declaration. /// The Host constructed an invalid response before operation execution.
Internal,
/// The encoded implementation result violates its declaration after execution.
InvalidResult, InvalidResult,
/// This object requires a validator and the call omitted it. /// This operation requires a validator and the call omitted it.
ValidatorRequired, ValidatorRequired,
/// The supplied object validator is stale or belongs to another object. /// The supplied object validator is stale or belongs to another object.
ValidatorMismatch, ValidatorMismatch,
@@ -533,6 +619,8 @@ pub enum CallErrorKind {
InterfaceValidatorRequired, InterfaceValidatorRequired,
/// The supplied interface validator does not identify the current descriptor. /// The supplied interface validator does not identify the current descriptor.
InterfaceValidatorMismatch, InterfaceValidatorMismatch,
/// The operation is declared but not authorized for the caller.
PermissionDenied,
/// The typed implementation reported a host/backend failure. /// The typed implementation reported a host/backend failure.
Implementation, Implementation,
} }
@@ -588,30 +676,31 @@ impl CallError {
&self.message &self.message
} }
/// Maps this host failure to the existing protocol error taxonomy. /// Maps this host failure to the canonical protocol error taxonomy.
///
/// Validator details remain available through [`CallError::kind`], while
/// the current protocol model represents conditional-request failures as
/// `InvalidRequest`. Result/codec and implementation failures are internal,
/// because exposing malformed host output as an application failure would
/// be unsafe.
#[must_use] #[must_use]
pub fn protocol_error(&self) -> ProtocolError { pub fn protocol_error(&self) -> ProtocolError {
let category = match self.kind { let code = match self.kind {
CallErrorKind::ObjectNotFound => ProtocolErrorCategory::ObjectNotFound, CallErrorKind::InvalidRequest => ProtocolErrorCode::InvalidRequest,
CallErrorKind::InterfaceMismatch => ProtocolErrorCategory::InterfaceNotFound, CallErrorKind::NotFound => ProtocolErrorCode::NotFound,
CallErrorKind::OperationNotFound => ProtocolErrorCategory::OperationNotFound, CallErrorKind::InterfaceMismatch => ProtocolErrorCode::InterfaceMismatch,
CallErrorKind::InvalidArguments => ProtocolErrorCategory::InvalidArguments, CallErrorKind::OperationNotFound => ProtocolErrorCode::OperationNotFound,
CallErrorKind::ValidatorRequired CallErrorKind::InvalidArguments => ProtocolErrorCode::InvalidArguments,
| CallErrorKind::ValidatorMismatch CallErrorKind::Internal => ProtocolErrorCode::Internal,
| CallErrorKind::InterfaceValidatorRequired
| CallErrorKind::InterfaceValidatorMismatch => ProtocolErrorCategory::InvalidRequest,
CallErrorKind::InvalidResult | CallErrorKind::Implementation => { CallErrorKind::InvalidResult | CallErrorKind::Implementation => {
ProtocolErrorCategory::Internal ProtocolErrorCode::OperationOutcomeUnknown
} }
CallErrorKind::ValidatorRequired => ProtocolErrorCode::ValidatorRequired,
CallErrorKind::ValidatorMismatch => ProtocolErrorCode::ValidatorMismatch,
CallErrorKind::InterfaceValidatorRequired => {
ProtocolErrorCode::InterfaceValidatorRequired
}
CallErrorKind::InterfaceValidatorMismatch => {
ProtocolErrorCode::InterfaceValidatorMismatch
}
CallErrorKind::PermissionDenied => ProtocolErrorCode::PermissionDenied,
}; };
ProtocolError { ProtocolError {
category, code,
message: self.message.clone(), message: self.message.clone(),
} }
} }
+490 -319
View File
@@ -1,14 +1,15 @@
use std::cell::{Cell, RefCell}; use std::cell::Cell;
use std::collections::BTreeMap;
use std::rc::Rc;
use wip_host::{ use wip_host::{
CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, PublishedObject, Validator, CallError, CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget,
WipObject, ObjectProvider, PublishedObject, RegisteredInterface, Target, Validator, WipObject,
}; };
use wip_interface::{ use wip_interface::{
CodecError, HostError, NamedValue, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, CodecError, HostError, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, interface,
interface,
}; };
use wip_protocol::{ObjectId, ProtocolErrorCategory}; use wip_protocol::ProtocolErrorCode;
#[interface] #[interface]
pub trait Reader { pub trait Reader {
@@ -17,6 +18,9 @@ pub trait Reader {
/// Adds two integers. /// Adds two integers.
fn add(&self, left: i64, right: i64) -> OperationResult<i64>; fn add(&self, left: i64, right: i64) -> OperationResult<i64>;
/// Reports a backend failure.
fn fail(&self) -> OperationResult<String>;
} }
#[interface] #[interface]
@@ -57,31 +61,37 @@ impl WipType for BadValue {
} }
struct Document { struct Document {
id: String, name: String,
description: Option<String>,
reference: Option<String>,
validator: Option<Validator>, validator: Option<Validator>,
require_validator: bool, require_validator: bool,
calls: Cell<usize>, calls: Rc<Cell<usize>>,
private_backend_handle: RefCell<String>, private_backend_handle: String,
} }
impl Document { impl Document {
fn new(id: &str) -> Self { fn open(name: &str, reference: Option<&str>, calls: Rc<Cell<usize>>) -> Self {
Self { Self {
id: id.into(), name: name.into(),
description: Some("public document".into()),
reference: reference.map(str::to_owned),
validator: None, validator: None,
require_validator: false, require_validator: false,
calls: Cell::new(0), calls,
private_backend_handle: RefCell::new("/private/backend/path".into()), private_backend_handle: "/private/backend/path".into(),
} }
} }
fn strict(id: &str, validator: &[u8]) -> Self { fn strict(name: &str, reference: &str, validator: &[u8], calls: Rc<Cell<usize>>) -> Self {
Self { Self {
id: id.into(), name: name.into(),
description: Some("public document".into()),
reference: Some(reference.into()),
validator: Some(Validator::new(validator)), validator: Some(Validator::new(validator)),
require_validator: true, require_validator: true,
calls: Cell::new(0), calls,
private_backend_handle: RefCell::new("secret-handle".into()), private_backend_handle: "secret-handle".into(),
} }
} }
@@ -91,15 +101,23 @@ impl Document {
} }
impl WipObject for Document { impl WipObject for Document {
fn id(&self) -> ObjectId { fn name(&self) -> &str {
ObjectId(self.id.clone()) &self.name
}
fn description(&self) -> Option<&str> {
self.description.as_deref()
}
fn reference(&self) -> Option<&str> {
self.reference.as_deref()
} }
fn validator(&self) -> Option<&Validator> { fn validator(&self) -> Option<&Validator> {
self.validator.as_ref() self.validator.as_ref()
} }
fn requires_validator(&self) -> bool { fn requires_validator(&self, _: &InterfaceRef, _: &str) -> bool {
self.require_validator self.require_validator
} }
} }
@@ -114,6 +132,11 @@ impl Reader for Document {
self.record_call(); self.record_call();
Ok(left + right) Ok(left + right)
} }
fn fail(&self) -> OperationResult<String> {
self.record_call();
Err(HostError::new("backend unavailable"))
}
} }
impl Writer for Document { impl Writer for Document {
@@ -137,361 +160,509 @@ impl Broken for Document {
} }
} }
struct TestProvider {
reader: RegisteredInterface<ReaderDefinition>,
writer: RegisteredInterface<WriterDefinition>,
broken: RegisteredInterface<BrokenDefinition>,
calls: Rc<Cell<usize>>,
resolutions: Cell<usize>,
authorization_checks: Cell<usize>,
deny: Cell<bool>,
switched: Cell<bool>,
}
impl TestProvider {
fn new(
reader: RegisteredInterface<ReaderDefinition>,
writer: RegisteredInterface<WriterDefinition>,
broken: RegisteredInterface<BrokenDefinition>,
) -> Self {
Self {
reader,
writer,
broken,
calls: Rc::new(Cell::new(0)),
resolutions: Cell::new(0),
authorization_checks: Cell::new(0),
deny: Cell::new(false),
switched: Cell::new(false),
}
}
}
impl ObjectProvider for TestProvider {
type Object = Document;
fn with_object<R>(
&self,
path: &str,
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
) -> Option<R> {
self.resolutions.set(self.resolutions.get() + 1);
let (document, interfaces) = match path {
"/document" => (
Document::strict(
"document",
"document-ref",
b"object-v1",
Rc::clone(&self.calls),
),
2,
),
"/open" => (
Document::open("open", Some("open-ref"), Rc::clone(&self.calls)),
2,
),
"/left/document" | "/right/document" => (
Document::strict(
"document",
"shared-ref",
b"shared-v1",
Rc::clone(&self.calls),
),
2,
),
"/switch" => {
let switched = self.switched.replace(true);
let (reference, validator) = if switched {
("second-ref", b"second".as_slice())
} else {
("first-ref", b"first".as_slice())
};
(
Document::strict("switch", reference, validator, Rc::clone(&self.calls)),
2,
)
}
"/empty" => (Document::open("empty", None, Rc::clone(&self.calls)), 0),
"/invalid-object" => (
Document::open("wrong-name", None, Rc::clone(&self.calls)),
0,
),
"/broken" => (
Document::open("broken", Some("broken-ref"), Rc::clone(&self.calls)),
3,
),
_ => return None,
};
let publication = PublishedObject::new(document);
let publication = match interfaces {
0 => publication,
2 => publication
.expose(&self.writer)
.unwrap()
.expose(&self.reader)
.unwrap(),
3 => publication.expose(&self.broken).unwrap(),
_ => unreachable!(),
};
Some(operation(&publication))
}
fn authorize(&self, _: &str, _: &Document, _: &InterfaceRef, _: &str) -> bool {
self.authorization_checks
.set(self.authorization_checks.get() + 1);
!self.deny.get()
}
}
fn registry_and_provider() -> (
InterfaceRegistry,
TestProvider,
RegisteredInterface<HiddenDefinition>,
) {
let mut registry = InterfaceRegistry::new();
let reader = registry
.register(ReaderDefinition, Some(Validator::new(b"schema-v1")))
.unwrap();
let writer = registry.register(WriterDefinition, None).unwrap();
let hidden = registry.register(HiddenDefinition, None).unwrap();
let broken = registry.register(BrokenDefinition, None).unwrap();
let provider = TestProvider::new(reader, writer, broken);
(registry, provider, hidden)
}
fn request( fn request(
object: &str, path: &str,
object_validator: Option<&[u8]>, object_validator: Option<&[u8]>,
reference: &wip_host::InterfaceRef, reference: &InterfaceRef,
interface_validator: Option<&[u8]>, interface_validator: Option<&[u8]>,
operation: &str, operation: &str,
arguments: Vec<NamedValue>, arguments: BTreeMap<String, Value>,
) -> CallRequest { ) -> CallRequest {
CallRequest { CallRequest {
object: ObjectId(object.into()), target: Target {
object_validator: object_validator.map(Validator::new), path: path.into(),
validator: object_validator.map(<[u8]>::to_vec),
},
interface: InterfaceTarget { interface: InterfaceTarget {
reference: reference.clone(), reference: reference.as_str().to_owned(),
validator: interface_validator.map(Validator::new), validator: interface_validator.map(<[u8]>::to_vec),
}, },
operation: operation.into(), operation: operation.into(),
arguments, arguments,
} }
} }
#[test] fn assert_failure(error: CallError, kind: CallErrorKind, code: ProtocolErrorCode) {
fn registry_exposes_opaque_validated_descriptor_observations() { assert_eq!(error.kind(), kind);
let mut registry = InterfaceRegistry::new(); assert_eq!(error.protocol_error().code, code);
let reader = registry.register(ReaderDefinition, None).unwrap();
assert_ne!(reader.reference().as_str(), "Reader");
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] #[test]
fn publication_is_explicit_ordered_qualified_and_request_scoped() { fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor() {
let mut registry = InterfaceRegistry::new(); let (registry, provider, _) = registry_and_provider();
let reader = registry.register(ReaderDefinition, None).unwrap(); let reference = provider.reader.reference();
let writer = registry.register(WriterDefinition, None).unwrap();
let _hidden = registry.register(HiddenDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("document-1")) assert_ne!(reference.as_str(), "Reader");
.expose(&writer) let observation = registry.fetch_interface(reference.as_str()).unwrap();
.unwrap() assert_eq!(observation.reference, *reference);
.expose(&reader) assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
.unwrap(); assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
let response = observation.protocol_response();
assert_eq!(response.interface, reference.as_str());
assert_eq!(response.descriptor, ReaderDefinition::descriptor());
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
response.descriptor.validate().unwrap();
let object = publication.protocol_object(); let error = registry.fetch_interface("unknown").unwrap_err();
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
}
#[test]
fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
let (_, provider, _) = registry_and_provider();
let object = provider.fetch("/document").unwrap();
assert_eq!(object.name, "document");
assert_eq!(object.description.as_deref(), Some("public document"));
assert_eq!(object.r#ref.as_deref(), Some("document-ref"));
assert_eq!(object.validator.as_deref(), Some(b"object-v1".as_slice()));
assert_eq!( assert_eq!(
object.interfaces, object.interfaces,
vec![ vec![
writer.reference().as_str().to_owned(), provider.writer.reference().as_str().to_owned(),
reader.reference().as_str().to_owned() provider.reader.reference().as_str().to_owned(),
] ]
); );
wip_protocol::InterfaceObservation { assert!(!format!("{object:?}").contains("secret-handle"));
object: object.clone(), assert!(!format!("{object:?}").contains("/document"));
descriptors: vec![
registry.observe(writer.reference()).unwrap().descriptor,
registry.observe(reader.reference()).unwrap().descriptor,
],
}
.validate()
.unwrap();
assert!(!format!("{object:?}").contains("private/backend/path"));
let writer_result = publication let empty = provider.fetch("/empty").unwrap();
.dispatch(request( assert!(empty.interfaces.is_empty());
"document-1", assert_eq!(empty.r#ref, None);
assert_eq!(empty.validator, None);
}
#[test]
fn duplicate_exposure_is_rejected_and_private_state_is_not_projected() {
let (_, provider, _) = registry_and_provider();
let publication = PublishedObject::new(Document::open(
"duplicate",
None, None,
writer.reference(), Rc::clone(&provider.calls),
None,
"label",
vec![],
)) ))
.expose(&provider.reader)
.unwrap(); .unwrap();
let reader_result = publication
.dispatch(request(
"document-1",
None,
reader.reference(),
None,
"label",
vec![],
))
.unwrap();
assert_eq!(writer_result.value, Some(Value::String("writer".into())));
assert_eq!(reader_result.value, Some(Value::String("reader".into())));
assert_eq!(publication.implementation().calls.get(), 2);
assert_eq!( assert_eq!(
publication publication.implementation().private_backend_handle,
.implementation()
.private_backend_handle
.borrow()
.as_str(),
"/private/backend/path" "/private/backend/path"
); );
} let error = match publication.expose(&provider.reader) {
#[test]
fn implemented_but_unexposed_interface_is_not_a_member() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let hidden = registry.register(HiddenDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("document-2"))
.expose(&reader)
.unwrap();
let error = publication
.dispatch(request(
"document-2",
None,
hidden.reference(),
None,
"hidden",
vec![],
))
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
assert_eq!(
error.protocol_error().category,
ProtocolErrorCategory::InterfaceNotFound
);
assert_eq!(publication.implementation().calls.get(), 0);
}
#[test]
fn zero_interface_objects_are_valid() {
let publication = PublishedObject::new(Document::new("empty"));
let object = publication.protocol_object();
assert_eq!(object.id, ObjectId("empty".into()));
assert!(object.interfaces.is_empty());
object.validate().unwrap();
}
#[test]
fn duplicate_exposure_is_rejected_without_reordering() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("duplicate"))
.expose(&reader)
.unwrap();
let error = match publication.expose(&reader) {
Ok(_) => panic!("duplicate exposure unexpectedly succeeded"), Ok(_) => panic!("duplicate exposure unexpectedly succeeded"),
Err(error) => error, Err(error) => error,
}; };
assert!(error.to_string().contains(reader.reference().as_str())); assert!(
} error
.to_string()
#[test] .contains(provider.reader.reference().as_str())
fn operation_and_argument_failures_are_mapped_without_execution() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("validation"))
.expose(&reader)
.unwrap();
let missing = publication
.dispatch(request(
"validation",
None,
reader.reference(),
None,
"missing",
vec![],
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::OperationNotFound);
assert_eq!(
missing.protocol_error().category,
ProtocolErrorCategory::OperationNotFound
); );
let invalid = publication
.dispatch(request(
"validation",
None,
reader.reference(),
None,
"add",
vec![NamedValue {
name: "left".into(),
value: Value::String("wrong".into()),
}],
))
.unwrap_err();
assert_eq!(invalid.kind(), CallErrorKind::InvalidArguments);
assert_eq!(
invalid.protocol_error().category,
ProtocolErrorCategory::InvalidArguments
);
assert_eq!(publication.implementation().calls.get(), 0);
} }
#[test] #[test]
fn invalid_implementation_result_is_internal_and_not_returned() { fn paths_are_resolution_context_not_object_identity() {
let mut registry = InterfaceRegistry::new(); let (_, provider, _) = registry_and_provider();
let broken = registry.register(BrokenDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("broken"))
.expose(&broken)
.unwrap();
let error = publication let left = provider.fetch("/left/document").unwrap();
.dispatch(request( let right = provider.fetch("/right/document").unwrap();
"broken", assert_eq!(left, right);
None, assert_eq!(left.r#ref.as_deref(), Some("shared-ref"));
broken.reference(),
None, let first = provider.fetch("/switch").unwrap();
"broken", let second = provider.fetch("/switch").unwrap();
vec![], assert_ne!(first.r#ref, second.r#ref);
)) assert_ne!(first.validator, second.validator);
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InvalidResult);
assert_eq!(
error.protocol_error().category,
ProtocolErrorCategory::Internal
);
assert_eq!(publication.implementation().calls.get(), 1);
} }
#[test] #[test]
fn object_validator_missing_and_mismatch_are_distinct_and_prevent_execution() { fn path_dispatch_is_interface_qualified_and_resolves_once_per_call() {
let mut registry = InterfaceRegistry::new(); let (_, provider, _) = registry_and_provider();
let reader = registry.register(ReaderDefinition, None).unwrap(); let before = provider.resolutions.get();
let publication = PublishedObject::new(Document::strict("strict", b"object-v1"))
.expose(&reader)
.unwrap();
let missing = publication let writer = provider
.dispatch(request( .call_operation(request(
"strict", "/open",
None, None,
reader.reference(), provider.writer.reference(),
None, None,
"label", "label",
vec![], BTreeMap::new(),
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::ValidatorRequired);
let mismatch = publication
.dispatch(request(
"strict",
Some(b"stale"),
reader.reference(),
None,
"label",
vec![],
))
.unwrap_err();
assert_eq!(mismatch.kind(), CallErrorKind::ValidatorMismatch);
assert_eq!(publication.implementation().calls.get(), 0);
let result = publication
.dispatch(request(
"strict",
Some(b"object-v1"),
reader.reference(),
None,
"label",
vec![],
)) ))
.unwrap(); .unwrap();
assert_eq!(result.validator, Some(Validator::new(b"object-v1"))); let reader = provider
assert_eq!(publication.implementation().calls.get(), 1); .call_operation(request(
} "/open",
#[test]
fn interface_validator_missing_and_mismatch_are_distinct_and_prevent_execution() {
let mut registry = InterfaceRegistry::new();
let writer = registry
.register(WriterDefinition, Some(Validator::new(b"schema-v1")))
.unwrap();
let publication = PublishedObject::new(Document::new("mutable-interface"))
.expose(&writer)
.unwrap();
let observation = registry.observe(writer.reference()).unwrap();
assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
let missing = publication
.dispatch(request(
"mutable-interface",
None, None,
writer.reference(), provider.reader.reference(),
None,
"label",
vec![],
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::InterfaceValidatorRequired);
let mismatch = publication
.dispatch(request(
"mutable-interface",
None,
writer.reference(),
Some(b"schema-v0"),
"label",
vec![],
))
.unwrap_err();
assert_eq!(mismatch.kind(), CallErrorKind::InterfaceValidatorMismatch);
assert_eq!(publication.implementation().calls.get(), 0);
let result = publication
.dispatch(request(
"mutable-interface",
None,
writer.reference(),
Some(b"schema-v1"), Some(b"schema-v1"),
"label", "label",
vec![], BTreeMap::new(),
)) ))
.unwrap(); .unwrap();
assert_eq!(result.value, Some(Value::String("writer".into())));
let strict = provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
Some(b"schema-v1"),
"label",
BTreeMap::new(),
))
.unwrap();
assert_eq!(writer.result, Value::String("writer".into()));
assert_eq!(reader.result, Value::String("reader".into()));
assert_eq!(strict.result, Value::String("reader".into()));
assert_eq!(strict.validator.as_deref(), Some(b"object-v1".as_slice()));
assert_eq!(provider.resolutions.get() - before, 3);
assert_eq!(provider.calls.get(), 3);
} }
#[test] #[test]
fn host_failures_map_to_internal_without_becoming_domain_values() { fn call_failures_follow_canonical_precedence_without_execution() {
#[interface] let (_, provider, hidden) = registry_and_provider();
pub trait Failing {
/// Always fails in the host implementation.
fn fail(&self) -> OperationResult<String>;
}
impl Failing for Document { let resolutions = provider.resolutions.get();
fn fail(&self) -> OperationResult<String> { for invalid_path in [
Err(HostError::new("backend unavailable")) "relative",
} "/trailing/",
} "/double//segment",
"/dot/./segment",
let mut registry = InterfaceRegistry::new(); "/parent/../segment",
let failing = registry.register(FailingDefinition, None).unwrap(); ] {
let publication = PublishedObject::new(Document::new("failure")) assert_failure(
.expose(&failing) provider
.unwrap(); .call_operation(request(
let error = publication invalid_path,
.dispatch(request( Some(b"stale"),
"failure", hidden.reference(),
None, Some(b"stale"),
failing.reference(), "missing",
None, BTreeMap::new(),
"fail",
vec![],
)) ))
.unwrap_err(); .unwrap_err(),
assert_eq!(error.kind(), CallErrorKind::Implementation); CallErrorKind::InvalidRequest,
assert_eq!( ProtocolErrorCode::InvalidRequest,
error.protocol_error().category, );
ProtocolErrorCategory::Internal }
assert_eq!(provider.resolutions.get(), resolutions);
assert_failure(
provider.fetch("/invalid/").unwrap_err(),
CallErrorKind::InvalidRequest,
ProtocolErrorCode::InvalidRequest,
);
assert_eq!(provider.resolutions.get(), resolutions);
assert_failure(
provider
.call_operation(request(
"/missing",
Some(b"stale"),
hidden.reference(),
Some(b"stale"),
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::NotFound,
ProtocolErrorCode::NotFound,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"stale"),
hidden.reference(),
None,
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::ValidatorMismatch,
ProtocolErrorCode::ValidatorMismatch,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
hidden.reference(),
None,
"hidden",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceMismatch,
ProtocolErrorCode::InterfaceMismatch,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
None,
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorRequired,
ProtocolErrorCode::InterfaceValidatorRequired,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
Some(b"stale-schema"),
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorMismatch,
ProtocolErrorCode::InterfaceValidatorMismatch,
);
assert_failure(
provider
.call_operation(request(
"/document",
None,
provider.reader.reference(),
Some(b"schema-v1"),
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::OperationNotFound,
ProtocolErrorCode::OperationNotFound,
);
assert_failure(
provider
.call_operation(request(
"/document",
None,
provider.reader.reference(),
Some(b"schema-v1"),
"add",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::ValidatorRequired,
ProtocolErrorCode::ValidatorRequired,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
Some(b"schema-v1"),
"add",
BTreeMap::from([("left".into(), Value::String("wrong".into()))]),
))
.unwrap_err(),
CallErrorKind::InvalidArguments,
ProtocolErrorCode::InvalidArguments,
);
provider.deny.set(true);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
Some(b"schema-v1"),
"label",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::PermissionDenied,
ProtocolErrorCode::PermissionDenied,
);
assert_eq!(provider.authorization_checks.get(), 1);
assert_eq!(provider.calls.get(), 0);
}
#[test]
fn immutable_interface_rejects_a_supplied_validator() {
let (_, provider, _) = registry_and_provider();
assert_failure(
provider
.call_operation(request(
"/open",
None,
provider.writer.reference(),
Some(b"unexpected"),
"label",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorMismatch,
ProtocolErrorCode::InterfaceValidatorMismatch,
);
assert_eq!(provider.calls.get(), 0);
}
#[test]
fn invalid_host_responses_and_uncertain_operation_failures_use_canonical_codes() {
let (_, provider, _) = registry_and_provider();
assert_failure(
provider.fetch("/invalid-object").unwrap_err(),
CallErrorKind::Internal,
ProtocolErrorCode::Internal,
);
assert_failure(
provider
.call_operation(request(
"/broken",
None,
provider.broken.reference(),
None,
"broken",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InvalidResult,
ProtocolErrorCode::OperationOutcomeUnknown,
);
assert_failure(
provider
.call_operation(request(
"/open",
None,
provider.reader.reference(),
Some(b"schema-v1"),
"fail",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::Implementation,
ProtocolErrorCode::OperationOutcomeUnknown,
); );
} }
@@ -1,6 +1,5 @@
use wip_host::{InterfaceRegistry, PublishedObject, WipObject}; use wip_host::{InterfaceRegistry, PublishedObject, WipObject};
use wip_interface::{OperationResult, interface}; use wip_interface::{OperationResult, interface};
use wip_protocol::ObjectId;
#[interface] #[interface]
pub trait Search { pub trait Search {
@@ -10,8 +9,8 @@ pub trait Search {
struct Object; struct Object;
impl WipObject for Object { impl WipObject for Object {
fn id(&self) -> ObjectId { fn name(&self) -> &str {
ObjectId("object".into()) "object"
} }
} }
@@ -1,27 +1,27 @@
error[E0277]: the trait bound `Object: Search` is not satisfied error[E0277]: the trait bound `Object: Search` is not satisfied
--> tests/ui/unimplemented_interface.rs:21:58 --> tests/ui/unimplemented_interface.rs:20:58
| |
21 | let _published = PublishedObject::new(Object).expose(&search).unwrap(); 20 | let _published = PublishedObject::new(Object).expose(&search).unwrap();
| ------ ^^^^^^^ unsatisfied trait bound | ------ ^^^^^^^ unsatisfied trait bound
| | | |
| required by a bound introduced by this call | required by a bound introduced by this call
| |
help: the trait `Search` is not implemented for `Object` help: the trait `Search` is not implemented for `Object`
--> tests/ui/unimplemented_interface.rs:10:1 --> tests/ui/unimplemented_interface.rs:9:1
| |
10 | struct Object; 9 | struct Object;
| ^^^^^^^^^^^^^ | ^^^^^^^^^^^^^
help: this trait has no implementations, consider adding one help: this trait has no implementations, consider adding one
--> tests/ui/unimplemented_interface.rs:6:1
|
6 | pub trait Search {
| ^^^^^^^^^^^^^^^^
note: required for `SearchDefinition` to implement `InterfaceImplementation<Object>`
--> tests/ui/unimplemented_interface.rs:5:1 --> tests/ui/unimplemented_interface.rs:5:1
| |
5 | #[interface] 5 | pub trait Search {
| ^^^^^^^^^^^^^^^^
note: required for `SearchDefinition` to implement `InterfaceImplementation<Object>`
--> tests/ui/unimplemented_interface.rs:4:1
|
4 | #[interface]
| ^^^^^^^^^^^^ | ^^^^^^^^^^^^
6 | pub trait Search { 5 | pub trait Search {
| ^^^^^^ | ^^^^^^
note: required by a bound in `PublishedObject::<T>::expose` note: required by a bound in `PublishedObject::<T>::expose`
--> src/lib.rs --> src/lib.rs
+178 -96
View File
@@ -1,4 +1,10 @@
//! Procedural macros backing the public `wip-interface` authoring facade. //! 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; use std::collections::HashSet;
@@ -162,9 +168,11 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
#(#declaration_collectors)* #(#declaration_collectors)*
::wip_interface::wip_protocol::InterfaceDescriptor { ::wip_interface::wip_protocol::InterfaceDescriptor {
name: ::std::string::String::from(stringify!(#trait_ident)), format: ::std::string::String::from(
::wip_interface::wip_protocol::INTERFACE_FORMAT_V1,
),
documentation: #trait_doc, documentation: #trait_doc,
declarations, types: declarations,
operations: ::std::vec![#(#operation_declarations),*], operations: ::std::vec![#(#operation_declarations),*],
} }
} }
@@ -174,9 +182,12 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
fn dispatch( fn dispatch(
implementation: &T, implementation: &T,
operation: &str, operation: &str,
arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, arguments: ::std::collections::BTreeMap<
::std::string::String,
::wip_interface::wip_protocol::Value,
>,
) -> ::std::result::Result< ) -> ::std::result::Result<
::std::option::Option<::wip_interface::wip_protocol::Value>, ::wip_interface::wip_protocol::Value,
::wip_interface::DispatchError, ::wip_interface::DispatchError,
> { > {
let descriptor = Self::descriptor(); let descriptor = Self::descriptor();
@@ -201,9 +212,12 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
pub fn dispatch<T: #trait_ident>( pub fn dispatch<T: #trait_ident>(
implementation: &T, implementation: &T,
operation: &str, operation: &str,
arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, arguments: ::std::collections::BTreeMap<
::std::string::String,
::wip_interface::wip_protocol::Value,
>,
) -> ::std::result::Result< ) -> ::std::result::Result<
::std::option::Option<::wip_interface::wip_protocol::Value>, ::wip_interface::wip_protocol::Value,
::wip_interface::DispatchError, ::wip_interface::DispatchError,
> { > {
<Self as ::wip_interface::InterfaceImplementation<T>>::dispatch( <Self as ::wip_interface::InterfaceImplementation<T>>::dispatch(
@@ -470,13 +484,18 @@ fn parse_operation_result(output: &ReturnType) -> syn::Result<Type> {
_ => None, _ => None,
}); });
let result = types.next().cloned(); 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( return Err(Error::new_spanned(
arguments, arguments,
"`OperationResult` must have exactly one application result type", "`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) { if direct_option(&result)?.is_some() || contains_option(&result) {
return Err(Error::new_spanned( return Err(Error::new_spanned(
result, result,
@@ -490,8 +509,13 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
let arguments_ident = &operation.arguments_ident; let arguments_ident = &operation.arguments_ident;
let fields = operation.parameters.iter().map(|parameter| { let fields = operation.parameters.iter().map(|parameter| {
let ident = &parameter.ident; let ident = &parameter.ident;
let name = &parameter.name;
let ty = parameter_rust_type(parameter); 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 decoders = operation.parameters.iter().map(|parameter| {
let ident = &parameter.ident; let ident = &parameter.ident;
@@ -507,12 +531,11 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
let encode_result = if operation.unit_result { let encode_result = if operation.unit_result {
quote! { quote! {
let () = result; let () = result;
::std::result::Result::Ok(::std::option::Option::None) ::std::result::Result::Ok(::wip_interface::wip_protocol::Value::Unit)
} }
} else { } else {
quote! { quote! {
<#result_ty as ::wip_interface::WipType>::encode(result) <#result_ty as ::wip_interface::WipType>::encode(result)
.map(::std::option::Option::Some)
} }
}; };
@@ -525,9 +548,12 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
impl #arguments_ident { impl #arguments_ident {
/// Decodes named logical values into typed operation arguments. /// Decodes named logical values into typed operation arguments.
pub fn decode( pub fn decode(
arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, arguments: ::std::collections::BTreeMap<
::std::string::String,
::wip_interface::wip_protocol::Value,
>,
) -> ::std::result::Result<Self, ::wip_interface::CodecError> { ) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
let mut decoder = ::wip_interface::__private::RecordDecoder::new(arguments)?; let mut decoder = ::wip_interface::__private::RecordDecoder::new(arguments);
let decoded = Self { let decoded = Self {
#(#decoders,)* #(#decoders,)*
}; };
@@ -539,7 +565,7 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
pub fn encode_result( pub fn encode_result(
result: #result_ty, result: #result_ty,
) -> ::std::result::Result< ) -> ::std::result::Result<
::std::option::Option<::wip_interface::wip_protocol::Value>, ::wip_interface::wip_protocol::Value,
::wip_interface::CodecError, ::wip_interface::CodecError,
> { > {
#encode_result #encode_result
@@ -559,22 +585,18 @@ fn operation_declaration(operation: &Operation) -> TokenStream2 {
quote! { quote! {
::wip_interface::wip_protocol::ParameterDeclaration { ::wip_interface::wip_protocol::ParameterDeclaration {
name: ::std::string::String::from(#parameter_name), name: ::std::string::String::from(#parameter_name),
required: !#optional,
documentation: #parameter_doc, documentation: #parameter_doc,
ty: <#ty as ::wip_interface::WipType>::type_expr(), r#type: <#ty as ::wip_interface::WipType>::type_expr(),
optional: #optional,
} }
} }
}); });
let returns = if operation.unit_result {
quote!(::std::option::Option::None)
} else {
let result_ty = &operation.result_ty; let result_ty = &operation.result_ty;
let return_doc = documentation_tokens(operation.return_documentation.as_ref()); let return_doc = documentation_tokens(operation.return_documentation.as_ref());
quote! { let returns = quote! {
::std::option::Option::Some(::wip_interface::wip_protocol::ReturnDeclaration { ::wip_interface::wip_protocol::ReturnDeclaration {
documentation: #return_doc, documentation: #return_doc,
ty: <#result_ty as ::wip_interface::WipType>::type_expr(), r#type: <#result_ty as ::wip_interface::WipType>::type_expr(),
})
} }
}; };
@@ -630,7 +652,7 @@ fn dispatch_arm(operation: &Operation) -> TokenStream2 {
let encoded = #arguments_ident::encode_result(result) let encoded = #arguments_ident::encode_result(result)
.map_err(::wip_interface::DispatchError::Encode)?; .map_err(::wip_interface::DispatchError::Encode)?;
descriptor descriptor
.validate_return(#operation_name, encoded.as_ref()) .validate_result(#operation_name, &encoded)
.map_err(::wip_interface::DispatchError::InvalidResult)?; .map_err(::wip_interface::DispatchError::InvalidResult)?;
::std::result::Result::Ok(encoded) ::std::result::Result::Ok(encoded)
} }
@@ -698,7 +720,12 @@ fn derive_record(
let mut parsed_fields = Vec::new(); let mut parsed_fields = Vec::new();
let mut names = HashSet::new(); let mut names = HashSet::new();
for field in fields { 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); let name = source_name(&field_ident);
if !names.insert(name.clone()) { if !names.insert(name.clone()) {
return Err(Error::new( return Err(Error::new(
@@ -726,9 +753,9 @@ fn derive_record(
quote! { quote! {
::wip_interface::wip_protocol::FieldDeclaration { ::wip_interface::wip_protocol::FieldDeclaration {
name: ::std::string::String::from(#name), name: ::std::string::String::from(#name),
required: !#optional,
documentation: #documentation, documentation: #documentation,
ty: <#ty as ::wip_interface::WipType>::type_expr(), r#type: <#ty as ::wip_interface::WipType>::type_expr(),
optional: #optional,
} }
} }
}); });
@@ -744,18 +771,18 @@ fn derive_record(
if field.optional { if field.optional {
quote! { quote! {
if let ::std::option::Option::Some(value) = #field_ident { if let ::std::option::Option::Some(value) = #field_ident {
fields.push(::wip_interface::wip_protocol::NamedValue { fields.insert(
name: ::std::string::String::from(#field_name), ::std::string::String::from(#field_name),
value: <#ty as ::wip_interface::WipType>::encode(value)?, <#ty as ::wip_interface::WipType>::encode(value)?,
}); );
} }
} }
} else { } else {
quote! { quote! {
fields.push(::wip_interface::wip_protocol::NamedValue { fields.insert(
name: ::std::string::String::from(#field_name), ::std::string::String::from(#field_name),
value: <#ty as ::wip_interface::WipType>::encode(#field_ident)?, <#ty as ::wip_interface::WipType>::encode(#field_ident)?,
}); );
} }
} }
}); });
@@ -773,9 +800,9 @@ fn derive_record(
Ok(quote! { Ok(quote! {
impl ::wip_interface::WipType for #ident { impl ::wip_interface::WipType for #ident {
fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr {
::wip_interface::wip_protocol::TypeExpr::Named( ::wip_interface::wip_protocol::TypeExpr::Named {
::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
) }
} }
fn collect_declarations( fn collect_declarations(
@@ -783,13 +810,13 @@ fn derive_record(
::wip_interface::wip_protocol::TypeDeclaration, ::wip_interface::wip_protocol::TypeDeclaration,
>, >,
) { ) {
let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Record( let declaration = ::wip_interface::wip_protocol::TypeDeclaration {
::wip_interface::wip_protocol::RecordDeclaration {
name: ::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
documentation: #declaration_doc, documentation: #declaration_doc,
definition: ::wip_interface::wip_protocol::TypeExpr::Record {
fields: ::std::vec![#(#field_declarations),*], fields: ::std::vec![#(#field_declarations),*],
}, },
); };
if ::wip_interface::__private::register_declaration(declarations, declaration) { if ::wip_interface::__private::register_declaration(declarations, declaration) {
#(#dependency_collectors)* #(#dependency_collectors)*
} }
@@ -802,7 +829,7 @@ fn derive_record(
::wip_interface::CodecError, ::wip_interface::CodecError,
> { > {
let Self { #(#destructured_fields),* } = self; let Self { #(#destructured_fields),* } = self;
let mut fields = ::std::vec::Vec::new(); let mut fields = ::std::collections::BTreeMap::new();
#(#encoders)* #(#encoders)*
::std::result::Result::Ok( ::std::result::Result::Ok(
::wip_interface::wip_protocol::Value::Record(fields), ::wip_interface::wip_protocol::Value::Record(fields),
@@ -814,7 +841,7 @@ fn derive_record(
) -> ::std::result::Result<Self, ::wip_interface::CodecError> { ) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
match value { match value {
::wip_interface::wip_protocol::Value::Record(fields) => { ::wip_interface::wip_protocol::Value::Record(fields) => {
let mut decoder = ::wip_interface::__private::RecordDecoder::new(fields)?; let mut decoder = ::wip_interface::__private::RecordDecoder::new(fields);
let decoded = Self { let decoded = Self {
#(#decoders,)* #(#decoders,)*
}; };
@@ -849,14 +876,14 @@ fn derive_enum(
.variants .variants
.iter() .iter()
.all(|variant| matches!(variant.fields, Fields::Unit)); .all(|variant| matches!(variant.fields, Fields::Unit));
let all_payload = !data.variants.is_empty() let valid_union = data.variants.iter().all(|variant| {
&& data.variants.iter().all(|variant| { matches!(variant.fields, Fields::Unit)
matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1) || matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1)
}); });
if all_unit { if all_unit {
derive_unit_enum(ident, type_name, type_documentation, data) derive_unit_enum(ident, type_name, type_documentation, data)
} else if all_payload { } else if valid_union {
derive_union_enum(ident, type_name, type_documentation, data) derive_union_enum(ident, type_name, type_documentation, data)
} else { } else {
let span = data let span = data
@@ -869,7 +896,7 @@ fn derive_enum(
.map_or_else(|| data.enum_token.span, Spanned::span); .map_or_else(|| data.enum_token.span, Spanned::span);
Err(Error::new( Err(Error::new(
span, span,
"a `WipType` enum must contain either only unit variants or only single-field tuple variants; mixed and named-field variants are unsupported", "a `WipType` enum must contain unit variants and/or single-field tuple variants; named-field and multi-field variants are unsupported",
)) ))
} }
} }
@@ -885,7 +912,7 @@ fn derive_unit_enum(
let name = source_name(&variant.ident); let name = source_name(&variant.ident);
let documentation = documentation_tokens(documentation(&variant.attrs).as_ref()); let documentation = documentation_tokens(documentation(&variant.attrs).as_ref());
quote! { quote! {
::wip_interface::wip_protocol::EnumVariantDeclaration { ::wip_interface::wip_protocol::EnumCase {
name: ::std::string::String::from(#name), name: ::std::string::String::from(#name),
documentation: #documentation, documentation: #documentation,
} }
@@ -905,9 +932,9 @@ fn derive_unit_enum(
Ok(quote! { Ok(quote! {
impl ::wip_interface::WipType for #ident { impl ::wip_interface::WipType for #ident {
fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr {
::wip_interface::wip_protocol::TypeExpr::Named( ::wip_interface::wip_protocol::TypeExpr::Named {
::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
) }
} }
fn collect_declarations( fn collect_declarations(
@@ -915,13 +942,13 @@ fn derive_unit_enum(
::wip_interface::wip_protocol::TypeDeclaration, ::wip_interface::wip_protocol::TypeDeclaration,
>, >,
) { ) {
let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Enum( let declaration = ::wip_interface::wip_protocol::TypeDeclaration {
::wip_interface::wip_protocol::EnumDeclaration {
name: ::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
documentation: #declaration_doc, documentation: #declaration_doc,
variants: ::std::vec![#(#declarations),*], definition: ::wip_interface::wip_protocol::TypeExpr::Enum {
cases: ::std::vec![#(#declarations),*],
}, },
); };
let _ = ::wip_interface::__private::register_declaration( let _ = ::wip_interface::__private::register_declaration(
declarations, declarations,
declaration, declaration,
@@ -938,7 +965,7 @@ fn derive_unit_enum(
#(#encoders,)* #(#encoders,)*
}; };
::std::result::Result::Ok( ::std::result::Result::Ok(
::wip_interface::wip_protocol::Value::Enum( ::wip_interface::wip_protocol::Value::String(
::std::string::String::from(variant), ::std::string::String::from(variant),
), ),
) )
@@ -948,7 +975,7 @@ fn derive_unit_enum(
value: ::wip_interface::wip_protocol::Value, value: ::wip_interface::wip_protocol::Value,
) -> ::std::result::Result<Self, ::wip_interface::CodecError> { ) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
match value { match value {
::wip_interface::wip_protocol::Value::Enum(variant) => { ::wip_interface::wip_protocol::Value::String(variant) => {
match variant.as_str() { match variant.as_str() {
#(#decoders,)* #(#decoders,)*
_ => ::std::result::Result::Err( _ => ::std::result::Result::Err(
@@ -977,30 +1004,36 @@ fn derive_union_enum(
struct Variant<'a> { struct Variant<'a> {
ident: &'a Ident, ident: &'a Ident,
name: String, name: String,
ty: &'a Type, payload: Option<&'a Type>,
documentation: Option<Documentation>, documentation: Option<Documentation>,
} }
let mut variants = Vec::new(); let mut variants = Vec::new();
for variant in &data.variants { for variant in &data.variants {
let Fields::Unnamed(fields) = &variant.fields else { let payload = match &variant.fields {
unreachable!("enum shape was checked before union generation"); Fields::Unit => None,
}; Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
let field = fields let field = fields.unnamed.first().expect("length checked");
.unnamed
.first()
.expect("union variants have one field");
if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) { if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) {
return Err(Error::new_spanned( return Err(Error::new_spanned(
&field.ty, &field.ty,
"`Option<T>` is only supported directly on DTO fields and operation parameters", "`Option<T>` is only supported directly on DTO fields and operation parameters",
)); ));
} }
validate_value_type(&field.ty, "union variant payload")?; validate_value_type(&field.ty, "union case payload")?;
Some(&field.ty)
}
_ => {
return Err(Error::new_spanned(
variant,
"a `WipType` union case must be unit-like or contain exactly one unnamed payload",
));
}
};
variants.push(Variant { variants.push(Variant {
ident: &variant.ident, ident: &variant.ident,
name: source_name(&variant.ident), name: source_name(&variant.ident),
ty: &field.ty, payload,
documentation: documentation(&variant.attrs), documentation: documentation(&variant.attrs),
}); });
} }
@@ -1009,50 +1042,95 @@ fn derive_union_enum(
let declarations = variants.iter().map(|variant| { let declarations = variants.iter().map(|variant| {
let name = &variant.name; let name = &variant.name;
let documentation = documentation_tokens(variant.documentation.as_ref()); let documentation = documentation_tokens(variant.documentation.as_ref());
let ty = variant.ty; let payload = match variant.payload {
Some(ty) => quote! {
::std::option::Option::Some(
<#ty as ::wip_interface::WipType>::type_expr(),
)
},
None => quote!(::std::option::Option::None),
};
quote! { quote! {
::wip_interface::wip_protocol::UnionVariantDeclaration { ::wip_interface::wip_protocol::UnionCase {
name: ::std::string::String::from(#name), name: ::std::string::String::from(#name),
documentation: #documentation, documentation: #documentation,
ty: <#ty as ::wip_interface::WipType>::type_expr(), payload: #payload,
} }
} }
}); });
let dependency_collectors = variants.iter().map(|variant| { let dependency_collectors = variants.iter().filter_map(|variant| {
let ty = variant.ty; variant.payload.map(
quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);) |ty| quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);),
)
}); });
let encoders = variants.iter().map(|variant| { let encoders = variants.iter().map(|variant| {
let variant_ident = variant.ident; let variant_ident = variant.ident;
let name = &variant.name; let name = &variant.name;
let ty = variant.ty; match variant.payload {
quote! { Some(ty) => quote! {
Self::#variant_ident(value) => { Self::#variant_ident(value) => {
::wip_interface::wip_protocol::Value::Union { let mut fields = ::std::collections::BTreeMap::new();
variant: ::std::string::String::from(#name), fields.insert(
value: ::std::boxed::Box::new( ::std::string::String::from(
<#ty as ::wip_interface::WipType>::encode(value)?, ::wip_interface::wip_protocol::UNION_CASE_FIELD,
), ),
::wip_interface::wip_protocol::Value::String(
::std::string::String::from(#name),
),
);
fields.insert(
::std::string::String::from(
::wip_interface::wip_protocol::UNION_VALUE_FIELD,
),
<#ty as ::wip_interface::WipType>::encode(value)?,
);
::wip_interface::wip_protocol::Value::Record(fields)
} }
},
None => quote! {
Self::#variant_ident => {
let mut fields = ::std::collections::BTreeMap::new();
fields.insert(
::std::string::String::from(
::wip_interface::wip_protocol::UNION_CASE_FIELD,
),
::wip_interface::wip_protocol::Value::String(
::std::string::String::from(#name),
),
);
::wip_interface::wip_protocol::Value::Record(fields)
} }
},
} }
}); });
let decoders = variants.iter().map(|variant| { let decoders = variants.iter().map(|variant| {
let variant_ident = variant.ident; let variant_ident = variant.ident;
let name = &variant.name; let name = &variant.name;
let ty = variant.ty; match variant.payload {
quote! { Some(ty) => quote! {
#name => <#ty as ::wip_interface::WipType>::decode(*value) #name => {
.map(Self::#variant_ident) let value = decoder.required::<#ty>(
::wip_interface::wip_protocol::UNION_VALUE_FIELD,
)?;
decoder.finish()?;
::std::result::Result::Ok(Self::#variant_ident(value))
}
},
None => quote! {
#name => {
decoder.finish()?;
::std::result::Result::Ok(Self::#variant_ident)
}
},
} }
}); });
Ok(quote! { Ok(quote! {
impl ::wip_interface::WipType for #ident { impl ::wip_interface::WipType for #ident {
fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr {
::wip_interface::wip_protocol::TypeExpr::Named( ::wip_interface::wip_protocol::TypeExpr::Named {
::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
) }
} }
fn collect_declarations( fn collect_declarations(
@@ -1060,13 +1138,13 @@ fn derive_union_enum(
::wip_interface::wip_protocol::TypeDeclaration, ::wip_interface::wip_protocol::TypeDeclaration,
>, >,
) { ) {
let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Union( let declaration = ::wip_interface::wip_protocol::TypeDeclaration {
::wip_interface::wip_protocol::UnionDeclaration {
name: ::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
documentation: #declaration_doc, documentation: #declaration_doc,
variants: ::std::vec![#(#declarations),*], definition: ::wip_interface::wip_protocol::TypeExpr::Union {
cases: ::std::vec![#(#declarations),*],
}, },
); };
if ::wip_interface::__private::register_declaration(declarations, declaration) { if ::wip_interface::__private::register_declaration(declarations, declaration) {
#(#dependency_collectors)* #(#dependency_collectors)*
} }
@@ -1088,26 +1166,30 @@ fn derive_union_enum(
value: ::wip_interface::wip_protocol::Value, value: ::wip_interface::wip_protocol::Value,
) -> ::std::result::Result<Self, ::wip_interface::CodecError> { ) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
match value { match value {
::wip_interface::wip_protocol::Value::Union { variant, value } => { ::wip_interface::wip_protocol::Value::Record(fields) => {
match variant.as_str() { let mut decoder =
::wip_interface::__private::RecordDecoder::new(fields);
let case = decoder.required::<::std::string::String>(
::wip_interface::wip_protocol::UNION_CASE_FIELD,
)?;
match case.as_str() {
#(#decoders,)* #(#decoders,)*
_ => ::std::result::Result::Err( _ => ::std::result::Result::Err(
::wip_interface::__private::decode_unknown_variant( ::wip_interface::__private::decode_unknown_variant(
#type_name, #type_name,
variant, case,
), ),
), ),
} }
} }
actual => ::std::result::Result::Err( actual => ::std::result::Result::Err(
::wip_interface::__private::decode_type_mismatch("union", actual), ::wip_interface::__private::decode_type_mismatch("union record", actual),
), ),
} }
} }
} }
}) })
} }
fn optional_type(ty: &Type, context: &str) -> syn::Result<(bool, Type)> { fn optional_type(ty: &Type, context: &str) -> syn::Result<(bool, Type)> {
if let Some(inner) = direct_option(ty)? { if let Some(inner) = direct_option(ty)? {
if contains_option(&inner) { if contains_option(&inner) {
+220 -104
View File
@@ -30,37 +30,63 @@
//! //!
//! | Rust type | Protocol type/value | //! | Rust type | Protocol type/value |
//! | --- | --- | //! | --- | --- |
//! | `()` as an operation result | no return declaration / no value | //! | `()` | `Unit` |
//! | `bool` | `Boolean` | //! | `bool` | `Boolean` |
//! | signed and unsigned integer primitives | JavaScript-lossless `Integer` | //! | signed and unsigned integer primitives | JavaScript-lossless `Integer` |
//! | `f32`, `f64` | finite `Number` | //! | `f32`, `f64` | finite `Number` |
//! | `String` | `String` | //! | `String` | `String` |
//! | [`Bytes`] | `Bytes` | //! | [`Bytes`] | `Bytes` |
//! | [`wip_protocol::JsonValue`] | `Json` | //! | [`JsonValue`] | schema-neutral `Json` value tree |
//! | [`Entry<K, V>`] | `Entry<K, V>` | //! | [`Entry`] | canonical absolute Worldspace path (`Entry`) |
//! | `Vec<T>` | `List<T>` | //! | `Vec<T>` | `List<T>` |
//! | a named-field struct deriving [`WipType`] | named record | //! | a named-field struct deriving [`WipType`] | named record |
//! | an all-unit enum deriving [`WipType`] | named enum | //! | an all-unit enum deriving [`WipType`] | named enum |
//! | an all-newtype enum deriving [`WipType`] | named tagged union | //! | an enum mixing unit and single-payload cases deriving [`WipType`] | named union |
//! //!
//! `Option<T>` is supported only directly as a record field or operation //! `Option<T>` is supported only directly as a record field or operation
//! parameter. It means omission, not a null value. A domain failure belongs in //! parameter. It means omission, not a null value. A domain failure belongs in
//! an application return enum/union and is returned as `Ok(value)`. //! an application return enum/union and is returned as `Ok(value)`.
//! [`HostError`] is reserved for host/runtime failure, so the required //! [`HostError`] is reserved for host/runtime failure, so the required
//! [`OperationResult`] convention keeps those two failure classes distinct. //! [`OperationResult`] convention keeps those two failure classes distinct.
//!
//! # Migration to canonical values
//!
//! Earlier releases exposed `Entry<K, V>` and semantic `Value::Json`,
//! `Value::Entry`, `Value::Enum`, and `Value::Union` variants. Those forms are
//! intentionally gone: [`Entry`] is now one validated absolute Worldspace path,
//! while JSON, enum, and union meaning comes only from the descriptor. Codecs
//! exchange only raw [`Value`] strings, records, lists, and primitives. Unit
//! operation results now cross the boundary as [`Value::Unit`] rather than an
//! absent value.
//!
//! # Supported interface syntax
//!
//! An interface is a public, non-generic trait whose operations use exactly
//! `&self`, simple named owned parameters, and an explicit
//! `OperationResult<T>`. `WipType` derives support named-field structs,
//! all-unit enums as protocol enums, and enums made from any mix of unit and
//! single-payload cases as protocol unions.
//!
//! The macros reject trait inheritance and associated items; const, async,
//! unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods;
//! borrowed values and custom lifetimes; tuple, unit, or generic structs;
//! unsupported named-field or multi-field enum variants; explicit discriminants; Rust unions;
//! and `Option` anywhere except a direct DTO field or operation parameter.
//! Unsupported inputs produce source-located compile errors rather than
//! generating a partial interface.
#![deny(missing_docs)] #![deny(missing_docs)]
use std::collections::BTreeMap;
use std::error::Error; use std::error::Error;
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
pub use wip_interface_macros::{WipType, interface}; pub use wip_interface_macros::{WipType, interface};
pub use wip_protocol; pub use wip_protocol;
pub use wip_protocol::{ pub use wip_protocol::{
Documentation, EnumDeclaration, EnumVariantDeclaration, FieldDeclaration, InterfaceDescriptor, Documentation, EnumCase, FieldDeclaration, InterfaceDescriptor, MAX_SAFE_INTEGER,
JsonMember, JsonValue, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NamedValue, OperationDeclaration, MIN_SAFE_INTEGER, OperationDeclaration, ParameterDeclaration, ReturnDeclaration,
ParameterDeclaration, RecordDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr, TypeDeclaration, TypeExpr, UnionCase, ValidationError, Value,
UnionDeclaration, UnionVariantDeclaration, ValidationError, Value,
}; };
/// Converts one Rust application type to and from its protocol representation. /// Converts one Rust application type to and from its protocol representation.
@@ -103,8 +129,8 @@ pub trait InterfaceImplementation<T>: InterfaceDefinition {
fn dispatch( fn dispatch(
implementation: &T, implementation: &T,
operation: &str, operation: &str,
arguments: Vec<NamedValue>, arguments: BTreeMap<String, Value>,
) -> Result<Option<Value>, DispatchError>; ) -> Result<Value, DispatchError>;
} }
/// Result convention required for generated interface operations. /// Result convention required for generated interface operations.
@@ -266,13 +292,101 @@ impl From<Bytes> for Vec<u8> {
} }
} }
/// A typed protocol key/value entry. /// A canonical absolute path to an entry in the current Worldspace.
#[derive(Debug, Clone, PartialEq, Eq)] ///
pub struct Entry<K, V> { /// This replaces the pre-canonical `Entry<K, V>` key/value pair. Construct an
/// Entry key. /// entry from a path such as `/articles/1`; application data associated with
pub key: K, /// that entry belongs in an ordinary record alongside this value.
/// Entry value. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub value: V, pub struct Entry(String);
impl Entry {
/// Validates and creates an entry path.
pub fn new(path: impl Into<String>) -> Result<Self, CodecError> {
let path = path.into();
validate_entry_path(&path)?;
Ok(Self(path))
}
/// Returns the canonical absolute Worldspace path.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
/// Consumes the entry and returns its path.
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl TryFrom<String> for Entry {
type Error = CodecError;
fn try_from(path: String) -> Result<Self, Self::Error> {
Self::new(path)
}
}
impl std::str::FromStr for Entry {
type Err = CodecError;
fn from_str(path: &str) -> Result<Self, Self::Err> {
Self::new(path)
}
}
/// One member in a [`JsonValue::Object`].
#[derive(Debug, Clone, PartialEq)]
pub struct JsonMember {
/// Member name.
pub name: String,
/// Member value.
pub value: JsonValue,
}
/// JSON data represented independently of any application schema.
///
/// Its codec uses the ordinary schema-neutral [`Value`] variants. JSON null is
/// [`Value::Unit`], arrays are [`Value::List`], and objects are
/// [`Value::Record`]. Protocol bytes have no JSON representation and decoding
/// a tree containing [`Value::Bytes`] is rejected.
#[derive(Debug, Clone, PartialEq)]
pub enum JsonValue {
/// JSON null.
Null,
/// A JSON boolean.
Boolean(bool),
/// An interoperable integral JSON number.
Integer(i64),
/// A finite non-integral JSON number.
Number(f64),
/// A JSON string.
String(String),
/// A JSON array.
Array(Vec<JsonValue>),
/// A JSON object. Duplicate member names are rejected while encoding.
Object(Vec<JsonMember>),
}
impl WipType for () {
fn type_expr() -> TypeExpr {
TypeExpr::Unit
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Unit)
}
fn decode(value: Value) -> Result<Self, CodecError> {
match value {
Value::Unit => Ok(()),
other => Err(CodecError::expected("unit", &other)),
}
}
} }
impl WipType for bool { impl WipType for bool {
@@ -441,52 +555,87 @@ impl WipType for JsonValue {
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {} fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> { fn encode(self) -> Result<Value, CodecError> {
validate_json(&self)?; encode_json(self)
Ok(Value::Json(self))
} }
fn decode(value: Value) -> Result<Self, CodecError> { fn decode(value: Value) -> Result<Self, CodecError> {
match value { decode_json(value)
Value::Json(value) => {
validate_json(&value)?;
Ok(value)
} }
other => Err(CodecError::expected("json", &other)), }
fn encode_json(value: JsonValue) -> Result<Value, CodecError> {
match value {
JsonValue::Null => Ok(Value::Unit),
JsonValue::Boolean(value) => Ok(Value::Boolean(value)),
JsonValue::Integer(value) => {
ensure_safe_integer(value)?;
Ok(Value::Integer(value))
}
JsonValue::Number(value) => {
ensure_finite(value)?;
Ok(Value::Number(value))
}
JsonValue::String(value) => Ok(Value::String(value)),
JsonValue::Array(values) => values
.into_iter()
.enumerate()
.map(|(index, value)| encode_json(value).map_err(|error| error.at(index.to_string())))
.collect::<Result<Vec<_>, _>>()
.map(Value::List),
JsonValue::Object(members) => {
let mut fields = BTreeMap::new();
for member in members {
let name = member.name;
let value = encode_json(member.value).map_err(|error| error.at(name.clone()))?;
if fields.insert(name.clone(), value).is_some() {
return Err(CodecError::new(format!("duplicate JSON member `{name}`")).at(name));
}
}
Ok(Value::Record(fields))
} }
} }
} }
fn validate_json(value: &JsonValue) -> Result<(), CodecError> { fn decode_json(value: Value) -> Result<JsonValue, CodecError> {
match value { match value {
JsonValue::Integer(value) => ensure_safe_integer(*value), Value::Unit => Ok(JsonValue::Null),
JsonValue::Number(value) => ensure_finite(*value), Value::Boolean(value) => Ok(JsonValue::Boolean(value)),
JsonValue::Array(values) => { Value::Integer(value) => {
for (index, value) in values.iter().enumerate() { ensure_safe_integer(value)?;
validate_json(value).map_err(|error| error.at(index.to_string()))?; Ok(JsonValue::Integer(value))
} }
Ok(()) Value::Number(value) => {
ensure_finite(value)?;
Ok(JsonValue::Number(value))
} }
JsonValue::Object(members) => { Value::String(value) => Ok(JsonValue::String(value)),
let mut names = std::collections::HashSet::new(); Value::Bytes(_) => Err(CodecError::new("bytes are not valid inside JSON")),
for member in members { Value::List(values) => values
if !names.insert(member.name.as_str()) { .into_iter()
return Err(CodecError::new(format!( .enumerate()
"duplicate JSON member `{}`", .map(|(index, value)| decode_json(value).map_err(|error| error.at(index.to_string())))
member.name .collect::<Result<Vec<_>, _>>()
)) .map(JsonValue::Array),
.at(member.name.clone())); Value::Record(fields) => fields
} .into_iter()
validate_json(&member.value).map_err(|error| error.at(member.name.clone()))?; .map(|(name, value)| {
} decode_json(value)
Ok(()) .map(|value| JsonMember {
} name: name.clone(),
JsonValue::Null | JsonValue::Boolean(_) | JsonValue::String(_) => Ok(()), value,
})
.map_err(|error| error.at(name))
})
.collect::<Result<Vec<_>, _>>()
.map(JsonValue::Object),
} }
} }
impl<T: WipType> WipType for Vec<T> { impl<T: WipType> WipType for Vec<T> {
fn type_expr() -> TypeExpr { fn type_expr() -> TypeExpr {
TypeExpr::List(Box::new(T::type_expr())) TypeExpr::List {
items: Box::new(T::type_expr()),
}
} }
fn collect_declarations(declarations: &mut Vec<TypeDeclaration>) { fn collect_declarations(declarations: &mut Vec<TypeDeclaration>) {
@@ -513,37 +662,31 @@ impl<T: WipType> WipType for Vec<T> {
} }
} }
impl<K: WipType, V: WipType> WipType for Entry<K, V> { impl WipType for Entry {
fn type_expr() -> TypeExpr { fn type_expr() -> TypeExpr {
TypeExpr::Entry { TypeExpr::Entry
key: Box::new(K::type_expr()),
value: Box::new(V::type_expr()),
}
} }
fn collect_declarations(declarations: &mut Vec<TypeDeclaration>) { fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
K::collect_declarations(declarations);
V::collect_declarations(declarations);
}
fn encode(self) -> Result<Value, CodecError> { fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Entry { validate_entry_path(&self.0)?;
key: Box::new(self.key.encode().map_err(|error| error.at("key"))?), Ok(Value::String(self.0))
value: Box::new(self.value.encode().map_err(|error| error.at("value"))?),
})
} }
fn decode(value: Value) -> Result<Self, CodecError> { fn decode(value: Value) -> Result<Self, CodecError> {
let Value::Entry { key, value } = value else { let Value::String(path) = value else {
return Err(CodecError::expected("entry", &value)); return Err(CodecError::expected("entry path string", &value));
}; };
Ok(Self { Self::new(path)
key: K::decode(*key).map_err(|error| error.at("key"))?,
value: V::decode(*value).map_err(|error| error.at("value"))?,
})
} }
} }
fn validate_entry_path(path: &str) -> Result<(), CodecError> {
wip_protocol::validate_path(path)
.map_err(|error| CodecError::new(format!("invalid canonical Worldspace path: {error}")))
}
/// Implementation details used by code emitted from the facade's macros. /// Implementation details used by code emitted from the facade's macros.
/// ///
/// These APIs are public only because macro expansions execute in the /// These APIs are public only because macro expansions execute in the
@@ -551,12 +694,9 @@ impl<K: WipType, V: WipType> WipType for Entry<K, V> {
/// and are not intended for direct use. /// and are not intended for direct use.
#[doc(hidden)] #[doc(hidden)]
pub mod __private { pub mod __private {
use std::collections::HashSet; use std::collections::BTreeMap;
use wip_protocol::{ use wip_protocol::{InterfaceDescriptor, TypeDeclaration, ValidationError, Value};
InterfaceDescriptor, NamedValue, ObjectId, OperationCall, OperationTarget, TypeDeclaration,
ValidationError, Value,
};
use crate::{CodecError, Documentation, WipType}; use crate::{CodecError, Documentation, WipType};
@@ -591,16 +731,9 @@ pub mod __private {
pub fn validate_arguments( pub fn validate_arguments(
descriptor: &InterfaceDescriptor, descriptor: &InterfaceDescriptor,
operation: &str, operation: &str,
arguments: &[NamedValue], arguments: &BTreeMap<String, Value>,
) -> Result<(), ValidationError> { ) -> Result<(), ValidationError> {
descriptor.validate_call(&OperationCall { descriptor.validate_arguments(operation, arguments)
target: OperationTarget {
object: ObjectId("<typed-dispatch>".into()),
interface: descriptor.name.clone(),
operation: operation.into(),
},
arguments: arguments.to_vec(),
})
} }
/// Builds a codec error for a generated structural mismatch. /// Builds a codec error for a generated structural mismatch.
@@ -618,22 +751,13 @@ pub mod __private {
/// Decoder for a protocol record or operation's named argument collection. /// Decoder for a protocol record or operation's named argument collection.
pub struct RecordDecoder { pub struct RecordDecoder {
fields: Vec<NamedValue>, fields: BTreeMap<String, Value>,
seen: HashSet<String>,
} }
impl RecordDecoder { impl RecordDecoder {
/// Starts decoding record fields or named operation arguments and /// Starts decoding record fields or named operation arguments.
/// rejects duplicate names. pub fn new(fields: BTreeMap<String, Value>) -> Self {
pub fn new(fields: Vec<NamedValue>) -> Result<Self, CodecError> { Self { fields }
let mut seen = HashSet::new();
for field in &fields {
if !seen.insert(field.name.clone()) {
return Err(CodecError::new(format!("duplicate field `{}`", field.name))
.at(field.name.clone()));
}
}
Ok(Self { fields, seen })
} }
/// Decodes a required field. /// Decodes a required field.
@@ -654,23 +778,15 @@ pub mod __private {
/// Rejects any fields that were not consumed by generated code. /// Rejects any fields that were not consumed by generated code.
pub fn finish(self) -> Result<(), CodecError> { pub fn finish(self) -> Result<(), CodecError> {
if let Some(field) = self.fields.first() { if let Some(name) = self.fields.keys().next() {
Err(CodecError::new(format!("unknown field `{}`", field.name)) Err(CodecError::new(format!("unknown field `{name}`")).at(name.clone()))
.at(field.name.clone()))
} else { } else {
Ok(()) Ok(())
} }
} }
fn take(&mut self, name: &str) -> Option<Value> { fn take(&mut self, name: &str) -> Option<Value> {
let index = self.fields.iter().position(|field| field.name == name)?; self.fields.remove(name)
Some(self.fields.remove(index).value)
}
/// Returns the set of names supplied to this decoder.
#[allow(dead_code)]
pub fn supplied_names(&self) -> &HashSet<String> {
&self.seen
} }
} }
} }
+112 -49
View File
@@ -1,8 +1,7 @@
use wip_interface::wip_protocol::{ use std::collections::BTreeMap;
EnumDeclaration, JsonMember, JsonValue, NamedValue, RecordDeclaration, TypeDeclaration,
TypeExpr, UnionDeclaration, Value, use wip_interface::wip_protocol::{TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, Value};
}; use wip_interface::{Bytes, Entry, JsonMember, JsonValue, WipType};
use wip_interface::{Bytes, Entry, WipType};
/// A request record. /// A request record.
#[derive(Debug, Clone, PartialEq, WipType)] #[derive(Debug, Clone, PartialEq, WipType)]
@@ -11,8 +10,8 @@ struct Request {
title: String, title: String,
/// Optional priority. /// Optional priority.
priority: Option<i32>, priority: Option<i32>,
/// Related entries. /// Related Worldspace entries.
related: Vec<Entry<String, bool>>, related: Vec<Entry>,
/// Opaque bytes. /// Opaque bytes.
body: Bytes, body: Bytes,
/// Arbitrary metadata. /// Arbitrary metadata.
@@ -38,33 +37,53 @@ struct Receipt {
/// Application-level publication outcome. /// Application-level publication outcome.
#[derive(Debug, Clone, PartialEq, WipType)] #[derive(Debug, Clone, PartialEq, WipType)]
enum PublishOutcome { enum PublishOutcome {
/// Work has not completed and carries no payload.
Pending,
/// Application success. /// Application success.
Published(Receipt), Published(Receipt),
/// Domain rejection, not a host failure. /// Domain rejection, not a host failure.
Rejected(String), Rejected(String),
} }
fn field<'a>(record: &'a BTreeMap<String, Value>, name: &str) -> &'a Value {
record
.get(name)
.unwrap_or_else(|| panic!("missing `{name}`"))
}
#[test] #[test]
fn supported_values_round_trip() { fn primitive_unit_record_list_entry_and_json_values_round_trip() {
assert_eq!(().encode().unwrap(), Value::Unit);
assert_eq!(<()>::decode(Value::Unit).unwrap(), ());
assert_eq!(<()>::type_expr(), TypeExpr::Unit);
let request = Request { let request = Request {
title: "hello".into(), title: "hello".into(),
priority: None, priority: None,
related: vec![Entry { related: vec![Entry::new("/articles/1").unwrap()],
key: "/articles/1".into(),
value: true,
}],
body: Bytes(vec![1, 2, 3]), body: Bytes(vec![1, 2, 3]),
metadata: JsonValue::Object(vec![JsonMember { metadata: JsonValue::Object(vec![JsonMember {
name: "source".into(), name: "source".into(),
value: JsonValue::String("test".into()), value: JsonValue::Array(vec![JsonValue::String("test".into()), JsonValue::Null]),
}]), }]),
}; };
let encoded = request.clone().encode().unwrap(); let encoded = request.clone().encode().unwrap();
let Value::Record(fields) = &encoded else { let Value::Record(fields) = &encoded else {
panic!("record derive must encode a protocol record") panic!("record derive must encode a raw protocol record")
}; };
assert!(fields.iter().all(|field| field.name != "priority")); assert!(!fields.contains_key("priority"));
assert_eq!(
field(fields, "related"),
&Value::List(vec![Value::String("/articles/1".into())])
);
assert_eq!(
field(fields, "metadata"),
&Value::Record(BTreeMap::from([(
"source".into(),
Value::List(vec![Value::String("test".into()), Value::Unit]),
)]))
);
assert_eq!(Request::decode(encoded).unwrap(), request); assert_eq!(Request::decode(encoded).unwrap(), request);
let number = 42.5_f64; let number = 42.5_f64;
@@ -77,92 +96,136 @@ fn supported_values_round_trip() {
f32::decode(narrow_number.encode().unwrap()).unwrap(), f32::decode(narrow_number.encode().unwrap()).unwrap(),
narrow_number narrow_number
); );
}
#[test]
fn enums_are_strings_and_unions_are_discriminated_records() {
for color in [Color::Red, Color::Blue] { for color in [Color::Red, Color::Blue] {
let encoded = color.clone().encode().unwrap(); let encoded = color.clone().encode().unwrap();
let decoded = Color::decode(encoded).unwrap(); assert!(matches!(encoded, Value::String(_)));
assert_eq!(decoded, color); assert_eq!(Color::decode(encoded).unwrap(), color);
} }
let pending = PublishOutcome::Pending.encode().unwrap();
assert_eq!(
pending,
Value::Record(BTreeMap::from([(
UNION_CASE_FIELD.into(),
Value::String("Pending".into()),
)]))
);
let published = PublishOutcome::Published(Receipt { number: 7 })
.encode()
.unwrap();
let Value::Record(fields) = &published else {
panic!("union must encode as a record")
};
assert_eq!(
fields.get(UNION_CASE_FIELD),
Some(&Value::String("Published".into()))
);
assert!(matches!(
fields.get(UNION_VALUE_FIELD),
Some(Value::Record(_))
));
for outcome in [ for outcome in [
PublishOutcome::Pending,
PublishOutcome::Published(Receipt { number: 7 }), PublishOutcome::Published(Receipt { number: 7 }),
PublishOutcome::Rejected("policy".into()), PublishOutcome::Rejected("policy".into()),
] { ] {
let encoded = outcome.clone().encode().unwrap(); let encoded = outcome.clone().encode().unwrap();
let decoded = PublishOutcome::decode(encoded).unwrap(); assert_eq!(PublishOutcome::decode(encoded).unwrap(), outcome);
assert_eq!(decoded, outcome);
} }
} }
#[test] #[test]
fn derived_declarations_include_docs_optionality_and_dependencies() { fn derived_declarations_use_canonical_named_type_expressions() {
let mut declarations = Vec::new(); let mut declarations = Vec::new();
Request::collect_declarations(&mut declarations); Request::collect_declarations(&mut declarations);
Color::collect_declarations(&mut declarations); Color::collect_declarations(&mut declarations);
PublishOutcome::collect_declarations(&mut declarations); PublishOutcome::collect_declarations(&mut declarations);
let TypeDeclaration::Record(RecordDeclaration { let request = declarations
documentation, .iter()
fields, .find(|declaration| declaration.name == "Request")
.. .unwrap();
}) = &declarations[0] assert_eq!(
else { request.documentation.as_ref().unwrap().summary,
"A request record."
);
let TypeExpr::Record { fields } = &request.definition else {
panic!("Request must be a record declaration") panic!("Request must be a record declaration")
}; };
assert_eq!(documentation.as_ref().unwrap().summary, "A request record.");
assert_eq!( assert_eq!(
fields[0].documentation.as_ref().unwrap().summary, fields[0].documentation.as_ref().unwrap().summary,
"Required title." "Required title."
); );
assert!(!fields[0].optional); assert!(fields[0].required);
assert!(fields[1].optional); assert!(!fields[1].required);
assert_eq!(fields[1].ty, TypeExpr::Integer); assert_eq!(fields[1].r#type, TypeExpr::Integer);
assert_eq!( assert_eq!(
fields[2].ty, fields[2].r#type,
TypeExpr::List(Box::new(TypeExpr::Entry { TypeExpr::List {
key: Box::new(TypeExpr::String), items: Box::new(TypeExpr::Entry),
value: Box::new(TypeExpr::Boolean), }
}))
); );
let color = declarations let color = declarations
.iter() .iter()
.find(|declaration| declaration.name() == "Color") .find(|declaration| declaration.name == "Color")
.unwrap(); .unwrap();
let TypeDeclaration::Enum(EnumDeclaration { variants, .. }) = color else { let TypeExpr::Enum { cases } = &color.definition else {
panic!("Color must be an enum declaration") panic!("Color must be an enum declaration")
}; };
assert_eq!( assert_eq!(
variants[0].documentation.as_ref().unwrap().summary, cases[0].documentation.as_ref().unwrap().summary,
"Warm red." "Warm red."
); );
let outcome = declarations let outcome = declarations
.iter() .iter()
.find(|declaration| declaration.name() == "PublishOutcome") .find(|declaration| declaration.name == "PublishOutcome")
.unwrap(); .unwrap();
let TypeDeclaration::Union(UnionDeclaration { variants, .. }) = outcome else { let TypeExpr::Union { cases } = &outcome.definition else {
panic!("PublishOutcome must be a union declaration") panic!("PublishOutcome must be a union declaration")
}; };
assert_eq!(variants[0].ty, TypeExpr::Named("Receipt".into())); assert_eq!(cases[0].payload, None);
assert_eq!(
cases[1].payload,
Some(TypeExpr::Named {
name: "Receipt".into(),
})
);
assert!( assert!(
declarations declarations
.iter() .iter()
.any(|declaration| declaration.name() == "Receipt") .any(|declaration| declaration.name == "Receipt")
); );
} }
#[test] #[test]
fn codecs_reject_invalid_boundaries() { fn codecs_reject_invalid_boundaries_and_json_bytes() {
assert!(i64::MAX.encode().is_err()); assert!(i64::MAX.encode().is_err());
assert!(f64::NAN.encode().is_err()); assert!(f64::NAN.encode().is_err());
assert!(Request::decode(Value::Record(vec![])).is_err()); assert!(Request::decode(Value::Record(BTreeMap::new())).is_err());
assert!( assert!(
Request::decode(Value::Record(vec![NamedValue { Request::decode(Value::Record(BTreeMap::from([(
name: "unknown".into(), "unknown".into(),
value: Value::String("value".into()), Value::String("value".into()),
}])) )])))
.is_err()
);
assert!(Color::decode(Value::String("Green".into())).is_err());
assert!(Entry::new("articles/1").is_err());
assert!(Entry::decode(Value::String("/articles/../1".into())).is_err());
assert!(JsonValue::decode(Value::Bytes(vec![1])).is_err());
assert!(
JsonValue::decode(Value::Record(BTreeMap::from([(
"nested".into(),
Value::List(vec![Value::Bytes(vec![1])]),
)])))
.is_err() .is_err()
); );
assert!(Color::decode(Value::Enum("Green".into())).is_err());
} }
+47 -51
View File
@@ -1,4 +1,6 @@
use wip_interface::wip_protocol::{NamedValue, TypeDeclaration, TypeExpr, Value}; use std::collections::BTreeMap;
use wip_interface::wip_protocol::{INTERFACE_FORMAT_V1, TypeExpr, Value};
use wip_interface::{DispatchError, HostError, OperationResult, WipType, interface}; use wip_interface::{DispatchError, HostError, OperationResult, WipType, interface};
/// Publication request. /// Publication request.
@@ -36,7 +38,7 @@ pub trait Catalog {
note: Option<String>, note: Option<String>,
) -> OperationResult<PublishOutcome>; ) -> OperationResult<PublishOutcome>;
/// Checks connectivity without returning an application value. /// Checks connectivity without returning application data.
fn ping(&self) -> OperationResult<()>; fn ping(&self) -> OperationResult<()>;
/// Demonstrates a host/runtime failure. /// Demonstrates a host/runtime failure.
@@ -88,36 +90,29 @@ impl Catalog for RejectingCatalog {
} }
} }
fn named(name: &str, value: Value) -> NamedValue { fn publish_arguments(with_note: bool) -> BTreeMap<String, Value> {
NamedValue { let mut arguments = BTreeMap::from([(
name: name.into(), "request".into(),
value,
}
}
fn publish_arguments(with_note: bool) -> Vec<NamedValue> {
let mut arguments = vec![named(
"request",
PublishRequest { PublishRequest {
title: "example".into(), title: "example".into(),
} }
.encode() .encode()
.unwrap(), .unwrap(),
)]; )]);
if with_note { if with_note {
arguments.push(named("note", Value::String("audit".into()))); arguments.insert("note".into(), Value::String("audit".into()));
} }
arguments arguments
} }
#[test] #[test]
fn descriptor_is_generated_only_from_the_trait_declaration() { fn descriptor_is_canonical_and_generated_only_from_the_trait_declaration() {
let first = CatalogDefinition::descriptor(); let first = CatalogDefinition::descriptor();
let second = CatalogDefinition::descriptor(); let second = CatalogDefinition::descriptor();
assert_eq!(first, second); assert_eq!(first, second);
first.validate().unwrap(); first.validate().unwrap();
assert_eq!(first.name, "Catalog"); assert_eq!(first.format, INTERFACE_FORMAT_V1);
assert_eq!( assert_eq!(
first.documentation.as_ref().unwrap().summary, first.documentation.as_ref().unwrap().summary,
"Catalog editing capability." "Catalog editing capability."
@@ -139,81 +134,82 @@ fn descriptor_is_generated_only_from_the_trait_declaration() {
.summary, .summary,
"Item to publish." "Item to publish."
); );
assert!(!publish.parameters[0].optional); assert!(publish.parameters[0].required);
assert!(publish.parameters[1].optional); assert!(!publish.parameters[1].required);
assert_eq!( assert_eq!(
publish publish.returns.documentation.as_ref().unwrap().summary,
.returns
.as_ref()
.unwrap()
.documentation
.as_ref()
.unwrap()
.summary,
"The application publication outcome." "The application publication outcome."
); );
assert_eq!( assert_eq!(
publish.returns.as_ref().unwrap().ty, publish.returns.r#type,
TypeExpr::Named("PublishOutcome".into()) TypeExpr::Named {
name: "PublishOutcome".into(),
}
); );
assert!(first.operations[1].returns.is_none()); assert_eq!(first.operations[1].returns.r#type, TypeExpr::Unit);
assert!( assert!(
first first
.declarations .types
.iter() .iter()
.any(|declaration| matches!(declaration, TypeDeclaration::Record(_))) .any(|declaration| matches!(declaration.definition, TypeExpr::Record { .. }))
); );
assert!( assert!(
first first
.declarations .types
.iter() .iter()
.any(|declaration| matches!(declaration, TypeDeclaration::Union(_))) .any(|declaration| matches!(declaration.definition, TypeExpr::Union { .. }))
); );
// Both implementation types satisfy the same generated dispatch boundary; // Both implementation types satisfy one immutable generated definition;
// neither can affect the descriptor shape. // implementation selection cannot contribute fields to the descriptor.
fn accepts_definition<T: Catalog>(_: &T) {} fn accepts_definition<T: Catalog>(_: &T) {}
accepts_definition(&AcceptingCatalog); accepts_definition(&AcceptingCatalog);
accepts_definition(&RejectingCatalog); accepts_definition(&RejectingCatalog);
assert_eq!(CatalogDefinition::descriptor(), first);
} }
#[test] #[test]
fn generated_argument_decoder_and_result_encoder_round_trip() { fn generated_argument_decoder_and_result_encoder_use_raw_values() {
let decoded = CatalogPublishArguments::decode(publish_arguments(false)).unwrap(); let decoded = CatalogPublishArguments::decode(publish_arguments(false)).unwrap();
assert_eq!(decoded.request.title, "example"); assert_eq!(decoded.request.title, "example");
assert_eq!(decoded.note, None); assert_eq!(decoded.note, None);
let encoded = CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())) let encoded =
.unwrap() CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())).unwrap();
.unwrap();
assert_eq!( assert_eq!(
PublishOutcome::decode(encoded).unwrap(), PublishOutcome::decode(encoded).unwrap(),
PublishOutcome::Rejected("domain".into()) PublishOutcome::Rejected("domain".into())
); );
assert_eq!(
CatalogPingArguments::encode_result(()).unwrap(),
Value::Unit
);
} }
#[test] #[test]
fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() { fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() {
let value = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)) let value =
.unwrap() CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)).unwrap();
.unwrap();
assert_eq!( assert_eq!(
PublishOutcome::decode(value).unwrap(), PublishOutcome::decode(value).unwrap(),
PublishOutcome::Published(true) PublishOutcome::Published(true)
); );
assert_eq!( assert_eq!(
CatalogDefinition::dispatch(&AcceptingCatalog, "ping", vec![]).unwrap(), CatalogDefinition::dispatch(&AcceptingCatalog, "ping", BTreeMap::new()).unwrap(),
None Value::Unit
); );
let invalid = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", vec![]).unwrap_err(); let invalid =
CatalogDefinition::dispatch(&AcceptingCatalog, "publish", BTreeMap::new()).unwrap_err();
assert!(matches!(invalid, DispatchError::InvalidArguments(_))); assert!(matches!(invalid, DispatchError::InvalidArguments(_)));
let host = CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", vec![]).unwrap_err(); let host =
CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", BTreeMap::new()).unwrap_err();
assert!(matches!(host, DispatchError::Host(error) if error.message() == "backend unavailable")); assert!(matches!(host, DispatchError::Host(error) if error.message() == "backend unavailable"));
let unknown = CatalogDefinition::dispatch(&AcceptingCatalog, "missing", vec![]).unwrap_err(); let unknown =
CatalogDefinition::dispatch(&AcceptingCatalog, "missing", BTreeMap::new()).unwrap_err();
assert!(matches!(unknown, DispatchError::UnknownOperation(name) if name == "missing")); assert!(matches!(unknown, DispatchError::UnknownOperation(name) if name == "missing"));
} }
@@ -245,11 +241,11 @@ fn distinct_operation_names_always_generate_distinct_public_items() {
assert_eq!(descriptor.operations[0].name, "item"); assert_eq!(descriptor.operations[0].name, "item");
assert_eq!(descriptor.operations[1].name, "item_"); assert_eq!(descriptor.operations[1].name, "item_");
let first = DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", vec![]) let first =
.unwrap() DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", BTreeMap::new())
.unwrap(); .unwrap();
let second = DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", vec![]) let second =
.unwrap() DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", BTreeMap::new())
.unwrap(); .unwrap();
assert_eq!(first, Value::String("item".into())); assert_eq!(first, Value::String("item".into()));
assert_eq!(second, Value::String("item_".into())); assert_eq!(second, Value::String("item_".into()));
@@ -0,0 +1,8 @@
use wip_interface::interface;
#[interface]
pub trait AsyncOperation {
async fn status(&self) -> wip_interface::OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,5 @@
error: async operations are not supported
--> tests/ui/async_operation.rs:5:5
|
5 | async fn status(&self) -> wip_interface::OperationResult<String>;
| ^^^^^
@@ -0,0 +1,10 @@
use wip_interface::interface;
#[interface]
pub trait DefaultMethod {
fn status(&self) -> wip_interface::OperationResult<String> {
Ok(String::new())
}
}
fn main() {}
@@ -0,0 +1,8 @@
error: WIP operation methods must not have a body
--> tests/ui/default_method.rs:5:64
|
5 | fn status(&self) -> wip_interface::OperationResult<String> {
| ________________________________________________________________^
6 | | Ok(String::new())
7 | | }
| |_____^
@@ -0,0 +1,8 @@
use wip_interface::WipType;
#[derive(WipType)]
struct GenericDto<T> {
value: T,
}
fn main() {}
@@ -0,0 +1,5 @@
error: generic DTOs are not supported by `WipType`
--> tests/ui/generic_dto.rs:4:18
|
4 | struct GenericDto<T> {
| ^^^
+2 -2
View File
@@ -1,9 +1,9 @@
use wip_interface::WipType; use wip_interface::WipType;
#[derive(WipType)] #[derive(WipType)]
enum MixedEnum { enum UnsupportedEnum {
Empty, Empty,
Payload(String), Payload { value: String },
} }
fn main() {} fn main() {}
@@ -1,5 +1,5 @@
error: a `WipType` enum must contain either only unit variants or only single-field tuple variants; mixed and named-field variants are unsupported error: a `WipType` enum must contain unit variants and/or single-field tuple variants; named-field and multi-field variants are unsupported
--> tests/ui/mixed_enum.rs:4:1 --> tests/ui/mixed_enum.rs:6:5
| |
4 | enum MixedEnum { 6 | Payload { value: String },
| ^^^^ | ^^^^^^^
@@ -0,0 +1,8 @@
use wip_interface::WipType;
#[derive(WipType)]
struct NestedOption {
values: Vec<Option<String>>,
}
fn main() {}
@@ -0,0 +1,5 @@
error: `Option<T>` must appear directly as the DTO field type
--> tests/ui/nested_option.rs:5:13
|
5 | values: Vec<Option<String>>,
| ^^^^^^^^^^^^^^^^^^^
@@ -4,16 +4,11 @@ error[E0277]: the trait bound `(std::string::String, std::string::String): WipTy
5 | fn load(&self, pair: (String, String)) -> OperationResult<String>; 5 | fn load(&self, pair: (String, String)) -> OperationResult<String>;
| ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)` | ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)`
| |
= help: the following other types implement trait `WipType`: help: the trait `WipType` is implemented for `()`
JsonValue --> src/lib.rs
Vec<T> |
bool | impl WipType for () {
f32 | ^^^^^^^^^^^^^^^^^^^
f64
i128
i16
i32
and $N others
error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied
--> tests/ui/unsupported_type.rs:5:26 --> tests/ui/unsupported_type.rs:5:26
@@ -24,16 +19,11 @@ error[E0277]: the trait bound `(std::string::String, std::string::String): WipTy
5 | fn load(&self, pair: (String, String)) -> OperationResult<String>; 5 | fn load(&self, pair: (String, String)) -> OperationResult<String>;
| ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)` | ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)`
| |
= help: the following other types implement trait `WipType`: help: the trait `WipType` is implemented for `()`
JsonValue --> src/lib.rs
Vec<T> |
bool | impl WipType for () {
f32 | ^^^^^^^^^^^^^^^^^^^
f64
i128
i16
i32
and $N others
note: required by a bound in `wip_interface::__private::RecordDecoder::required` note: required by a bound in `wip_interface::__private::RecordDecoder::required`
--> src/lib.rs --> src/lib.rs
| |
+114 -6
View File
@@ -223,10 +223,29 @@ impl Display for ValidationError {
impl Error for ValidationError {} impl Error for ValidationError {}
/// Stable protocol failure code returned by a host or produced by a client. /// One core interaction whose failure codes form a closed set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolInteraction {
/// Fetch one object by its Worldspace path.
Fetch,
/// Fetch a bounded tree of objects.
FetchTree,
/// Fetch one interface descriptor by its opaque reference.
FetchInterface,
/// Invoke one operation on a path-targeted object.
CallOperation,
}
/// Stable protocol failure code returned by a host.
///
/// This is the complete wire-level code set. A Client treats a malformed success
/// or failure response, including a code disallowed for its interaction, as a
/// Client-local `InvalidResponse`. An unsupported descriptor format is likewise
/// a Client-local `UnsupportedDescriptorFormat`; neither condition is a code a
/// Host can return.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolErrorCode { pub enum ProtocolErrorCode {
/// The request is structurally invalid. /// The request is structurally invalid before an operation descriptor is fixed.
InvalidRequest, InvalidRequest,
/// The target object is not published. /// The target object is not published.
NotFound, NotFound,
@@ -236,10 +255,8 @@ pub enum ProtocolErrorCode {
InterfaceMismatch, InterfaceMismatch,
/// The selected operation is not declared by the current interface. /// The selected operation is not declared by the current interface.
OperationNotFound, OperationNotFound,
/// Supplied operation arguments are invalid. /// Supplied arguments do not match the fixed operation declaration.
InvalidArguments, InvalidArguments,
/// A host result does not satisfy the declaration.
InvalidResult,
/// The operation requires an object validator and none was supplied. /// The operation requires an object validator and none was supplied.
ValidatorRequired, ValidatorRequired,
/// The supplied object validator does not match current state. /// The supplied object validator does not match current state.
@@ -251,9 +268,88 @@ pub enum ProtocolErrorCode {
/// A published operation is not permitted for the caller. /// A published operation is not permitted for the caller.
PermissionDenied, PermissionDenied,
/// A bounded protocol resource limit was exceeded. /// A bounded protocol resource limit was exceeded.
///
/// For `call_operation`, the Host guarantees that the operation was not run
/// or that all of its effects were rolled back.
ResourceLimitExceeded, ResourceLimitExceeded,
/// The host encountered an internal failure. /// The Host encountered an internal failure.
///
/// For `call_operation`, the Host guarantees that the operation was not run
/// or that all of its effects were rolled back.
Internal, Internal,
/// The Host cannot guarantee that the operation was not run or rolled back.
///
/// A Client must not automatically retry because the operation may have
/// committed effects even though no success response was available.
OperationOutcomeUnknown,
}
impl ProtocolErrorCode {
/// Every canonical wire-level error code.
pub const ALL: [Self; 14] = [
Self::InvalidRequest,
Self::NotFound,
Self::InterfaceNotFound,
Self::InterfaceMismatch,
Self::OperationNotFound,
Self::InvalidArguments,
Self::ValidatorRequired,
Self::ValidatorMismatch,
Self::InterfaceValidatorRequired,
Self::InterfaceValidatorMismatch,
Self::PermissionDenied,
Self::ResourceLimitExceeded,
Self::Internal,
Self::OperationOutcomeUnknown,
];
/// Whether a Host may return this code for `interaction`.
///
/// `false` identifies an invalid error response; it does not turn that
/// response into another wire-level protocol failure.
#[must_use]
pub const fn is_allowed_for(self, interaction: ProtocolInteraction) -> bool {
use ProtocolErrorCode::{
InterfaceMismatch, InterfaceNotFound, InterfaceValidatorMismatch,
InterfaceValidatorRequired, Internal, InvalidArguments, InvalidRequest, NotFound,
OperationNotFound, OperationOutcomeUnknown, PermissionDenied, ResourceLimitExceeded,
ValidatorMismatch, ValidatorRequired,
};
use ProtocolInteraction::{CallOperation, Fetch, FetchInterface, FetchTree};
match (interaction, self) {
(_, InvalidRequest | ResourceLimitExceeded | Internal) => true,
(Fetch | FetchTree, NotFound) => true,
(FetchInterface, InterfaceNotFound) => true,
(
CallOperation,
NotFound
| InterfaceMismatch
| OperationNotFound
| InvalidArguments
| ValidatorRequired
| ValidatorMismatch
| InterfaceValidatorRequired
| InterfaceValidatorMismatch
| PermissionDenied
| OperationOutcomeUnknown,
) => true,
(
_,
NotFound
| InterfaceNotFound
| InterfaceMismatch
| OperationNotFound
| InvalidArguments
| ValidatorRequired
| ValidatorMismatch
| InterfaceValidatorRequired
| InterfaceValidatorMismatch
| PermissionDenied
| OperationOutcomeUnknown,
) => false,
}
}
} }
/// Transport-independent protocol failure. /// Transport-independent protocol failure.
@@ -264,3 +360,15 @@ pub struct ProtocolError {
/// Bounded human-readable diagnostic. Consumers must not branch on it. /// Bounded human-readable diagnostic. Consumers must not branch on it.
pub message: String, pub message: String,
} }
impl Display for ProtocolError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
write!(
formatter,
"protocol failure ({:?}): {}",
self.code, self.message
)
}
}
impl Error for ProtocolError {}
+16 -3
View File
@@ -16,7 +16,20 @@
//! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] | //! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] |
//! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] | //! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] |
//! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] | //! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] |
//! | Stable failure taxonomy | [`ProtocolErrorCode`] / [`ProtocolError`] | //! | Stable Host failure taxonomy | [`ProtocolErrorCode`] / [`ProtocolError`] |
//! | Per-interaction allowed failure codes | [`ProtocolInteraction`] / [`ProtocolErrorCode::is_allowed_for`] |
//!
//! [`ProtocolErrorCode`] is a closed set of Host-returned wire failures. After a
//! Client receives a response, an invalid Object, Descriptor, operation result,
//! malformed error, or error code disallowed by [`ProtocolInteraction`] is a
//! Client-local `InvalidResponse`, not another wire error. A descriptor whose
//! `format` is not supported is a Client-local `UnsupportedDescriptorFormat`.
//!
//! For `call_operation`, [`ProtocolErrorCode::Internal`] and
//! [`ProtocolErrorCode::ResourceLimitExceeded`] guarantee that the operation did
//! not run or that all effects were rolled back. Only
//! [`ProtocolErrorCode::OperationOutcomeUnknown`] means the Host cannot make that
//! guarantee; Clients must not automatically retry it.
//! //!
//! Paths, object refs, interface refs, and validators remain separate concepts. //! Paths, object refs, interface refs, and validators remain separate concepts.
//! They are represented by ordinary protocol strings/bytes in the corresponding //! They are represented by ordinary protocol strings/bytes in the corresponding
@@ -35,8 +48,8 @@ mod model;
mod validation; mod validation;
pub use error::{ pub use error::{
NameNamespace, PathSegment, ProtocolError, ProtocolErrorCode, ValidationError, NameNamespace, PathSegment, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
ValidationErrorKind, ValueKind, ValidationError, ValidationErrorKind, ValueKind,
}; };
pub use model::{ pub use model::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
+14
View File
@@ -379,6 +379,9 @@ impl FetchRequest {
} }
/// Validates an object returned for this request. /// Validates an object returned for this request.
///
/// Failure means the Client received an invalid success response; it is not a
/// Host-returned [`crate::ProtocolErrorCode`].
pub fn validate_response(&self, response: &FetchResponse) -> Result<(), ValidationError> { pub fn validate_response(&self, response: &FetchResponse) -> Result<(), ValidationError> {
response.validate_at_path(&self.path) response.validate_at_path(&self.path)
} }
@@ -393,6 +396,9 @@ impl FetchTreeRequest {
impl FetchTreeResponse { impl FetchTreeResponse {
/// Validates tree shape, names, depth, and node limit for `request`. /// Validates tree shape, names, depth, and node limit for `request`.
///
/// Failure means the Client received an invalid success response; it is not a
/// Host-returned [`crate::ProtocolErrorCode`].
pub fn validate_for(&self, request: &FetchTreeRequest) -> Result<(), ValidationError> { pub fn validate_for(&self, request: &FetchTreeRequest) -> Result<(), ValidationError> {
crate::validation::validate_fetch_tree_response(self, request) crate::validation::validate_fetch_tree_response(self, request)
} }
@@ -400,6 +406,11 @@ impl FetchTreeResponse {
impl FetchInterfaceResponse { impl FetchInterfaceResponse {
/// Validates request correspondence and descriptor semantics. /// Validates request correspondence and descriptor semantics.
///
/// Failure means the Client received an invalid success response; it is not a
/// Host-returned [`crate::ProtocolErrorCode`]. Descriptor format support is a
/// separate Client concern: an otherwise valid descriptor with an unsupported
/// `format` is a Client-local `UnsupportedDescriptorFormat`.
pub fn validate_for(&self, request: &FetchInterfaceRequest) -> Result<(), ValidationError> { pub fn validate_for(&self, request: &FetchInterfaceRequest) -> Result<(), ValidationError> {
crate::validation::validate_fetch_interface_response(self, request) crate::validation::validate_fetch_interface_response(self, request)
} }
@@ -414,6 +425,9 @@ impl CallOperationRequest {
impl CallOperationResponse { impl CallOperationResponse {
/// Validates this result against the named operation in `descriptor`. /// Validates this result against the named operation in `descriptor`.
///
/// Failure means the Client received an invalid success response; it is not a
/// Host-returned [`crate::ProtocolErrorCode`].
pub fn validate_for( pub fn validate_for(
&self, &self,
descriptor: &InterfaceDescriptor, descriptor: &InterfaceDescriptor,
+133 -7
View File
@@ -5,8 +5,8 @@ use wip_protocol::{
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration,
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace,
Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError,
ProtocolErrorCode, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target,
UnionCase, ValidationErrorKind, Value, ValueKind, TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind,
}; };
fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value { fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
@@ -729,15 +729,16 @@ fn fetch_tree_dtos_validate_names_depth_limit_and_siblings() {
} }
#[test] #[test]
fn protocol_result_and_every_canonical_error_code_are_available() { fn protocol_result_exposes_the_exact_canonical_wire_error_codes() {
let codes = [ assert_eq!(
ProtocolErrorCode::ALL,
[
ProtocolErrorCode::InvalidRequest, ProtocolErrorCode::InvalidRequest,
ProtocolErrorCode::NotFound, ProtocolErrorCode::NotFound,
ProtocolErrorCode::InterfaceNotFound, ProtocolErrorCode::InterfaceNotFound,
ProtocolErrorCode::InterfaceMismatch, ProtocolErrorCode::InterfaceMismatch,
ProtocolErrorCode::OperationNotFound, ProtocolErrorCode::OperationNotFound,
ProtocolErrorCode::InvalidArguments, ProtocolErrorCode::InvalidArguments,
ProtocolErrorCode::InvalidResult,
ProtocolErrorCode::ValidatorRequired, ProtocolErrorCode::ValidatorRequired,
ProtocolErrorCode::ValidatorMismatch, ProtocolErrorCode::ValidatorMismatch,
ProtocolErrorCode::InterfaceValidatorRequired, ProtocolErrorCode::InterfaceValidatorRequired,
@@ -745,8 +746,9 @@ fn protocol_result_and_every_canonical_error_code_are_available() {
ProtocolErrorCode::PermissionDenied, ProtocolErrorCode::PermissionDenied,
ProtocolErrorCode::ResourceLimitExceeded, ProtocolErrorCode::ResourceLimitExceeded,
ProtocolErrorCode::Internal, ProtocolErrorCode::Internal,
]; ProtocolErrorCode::OperationOutcomeUnknown,
assert_eq!(codes.len(), 14); ]
);
let failure: ProtocolResult<()> = ProtocolResult::Failure(ProtocolError { let failure: ProtocolResult<()> = ProtocolResult::Failure(ProtocolError {
code: ProtocolErrorCode::InvalidArguments, code: ProtocolErrorCode::InvalidArguments,
@@ -758,3 +760,127 @@ fn protocol_result_and_every_canonical_error_code_are_available() {
ProtocolResult::Success(Value::Unit) ProtocolResult::Success(Value::Unit)
); );
} }
fn assert_allowed_codes(interaction: ProtocolInteraction, expected: &[ProtocolErrorCode]) {
let actual: Vec<_> = ProtocolErrorCode::ALL
.into_iter()
.filter(|code| code.is_allowed_for(interaction))
.collect();
assert_eq!(actual, expected);
for code in ProtocolErrorCode::ALL {
assert_eq!(
code.is_allowed_for(interaction),
expected.contains(&code),
"unexpected {code:?} validity for {interaction:?}"
);
}
}
#[test]
fn fetch_error_codes_are_exhaustive() {
assert_allowed_codes(
ProtocolInteraction::Fetch,
&[
ProtocolErrorCode::InvalidRequest,
ProtocolErrorCode::NotFound,
ProtocolErrorCode::ResourceLimitExceeded,
ProtocolErrorCode::Internal,
],
);
}
#[test]
fn fetch_tree_error_codes_are_exhaustive() {
assert_allowed_codes(
ProtocolInteraction::FetchTree,
&[
ProtocolErrorCode::InvalidRequest,
ProtocolErrorCode::NotFound,
ProtocolErrorCode::ResourceLimitExceeded,
ProtocolErrorCode::Internal,
],
);
}
#[test]
fn fetch_interface_error_codes_are_exhaustive() {
assert_allowed_codes(
ProtocolInteraction::FetchInterface,
&[
ProtocolErrorCode::InvalidRequest,
ProtocolErrorCode::InterfaceNotFound,
ProtocolErrorCode::ResourceLimitExceeded,
ProtocolErrorCode::Internal,
],
);
}
#[test]
fn call_operation_error_codes_are_exhaustive() {
assert_allowed_codes(
ProtocolInteraction::CallOperation,
&[
ProtocolErrorCode::InvalidRequest,
ProtocolErrorCode::NotFound,
ProtocolErrorCode::InterfaceMismatch,
ProtocolErrorCode::OperationNotFound,
ProtocolErrorCode::InvalidArguments,
ProtocolErrorCode::ValidatorRequired,
ProtocolErrorCode::ValidatorMismatch,
ProtocolErrorCode::InterfaceValidatorRequired,
ProtocolErrorCode::InterfaceValidatorMismatch,
ProtocolErrorCode::PermissionDenied,
ProtocolErrorCode::ResourceLimitExceeded,
ProtocolErrorCode::Internal,
ProtocolErrorCode::OperationOutcomeUnknown,
],
);
}
#[test]
fn invalid_success_payloads_remain_client_local_validation_failures() {
let fetch = FetchRequest {
path: "/articles/one".into(),
};
assert!(matches!(
fetch.validate_response(&object("other")).unwrap_err().kind,
ValidationErrorKind::ObjectNameMismatch { .. }
));
let interface_request = FetchInterfaceRequest {
interface: "catalog-v1".into(),
};
let mut invalid_descriptor = descriptor();
invalid_descriptor
.operations
.push(invalid_descriptor.operations[0].clone());
assert!(matches!(
FetchInterfaceResponse {
interface: interface_request.interface.clone(),
descriptor: invalid_descriptor,
validator: None,
}
.validate_for(&interface_request)
.unwrap_err()
.kind,
ValidationErrorKind::DuplicateName {
namespace: NameNamespace::Operation,
..
}
));
assert!(matches!(
CallOperationResponse {
result: Value::String("not a boolean".into()),
validator: None,
}
.validate_for(&descriptor(), "publish")
.unwrap_err()
.kind,
ValidationErrorKind::TypeMismatch {
expected: ValueKind::Boolean,
actual: ValueKind::String,
}
));
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "wip-public-api-example"
version = "0.1.0"
edition = "2024"
description = "Public-only end-to-end conformance consumer for the WIP Rust APIs"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
wip-host = { path = "../wip-host" }
wip-interface = { path = "../wip-interface" }
wip-protocol = { path = "../wip-protocol" }
+508
View File
@@ -0,0 +1,508 @@
//! Public-only conformance consumer for the WIP Rust crates.
//!
//! # Architectural boundary
//!
//! This package is an ordinary application consumer. Its direct dependencies are
//! the public `wip-protocol`, `wip-interface`, and `wip-host` crates; it neither
//! imports workspace-private modules nor depends on `wip-interface-macros`.
//! `wip-protocol` owns transport-independent DTOs and schema-neutral values,
//! while this package owns its application DTOs and interface traits. The
//! request-scoped implementation, backend handle, authorization policy, and
//! publication wiring remain host-private. A transport binding would translate
//! its own wire routes and envelopes to the protocol call DTOs; no HTTP type is
//! part of an application trait or interface descriptor.
//!
//! # Breaking migration exercised here
//!
//! The canonical API targets operations by `target.path` rather than a mandatory
//! object identifier. Object `ref` and object/interface validators are separate,
//! optional observation fields. Interface references are opaque values observed
//! separately from descriptors, and descriptors contain neither a Rust trait
//! name nor an opaque-reference `name` field. Application records, enums,
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
//! variants: entries and enums are strings, unions are discriminator records,
//! and JSON recursively uses ordinary records/lists/primitives. The former
//! `Entry<K, V>` and semantic `Value::Entry`, `Value::Enum`, `Value::Union`, and
//! `Value::Json` forms are intentionally unsupported.
//!
//! # Supported authoring subset
//!
//! Public, non-generic traits use immutable `&self` receivers, owned named
//! parameters, and explicit `wip::OperationResult<T>` returns. Derived DTOs are
//! named-field records, all-unit enums, or unions with unit and single-payload
//! cases. A direct `Option<T>` means field/argument omission, never protocol
//! null. See `wip-interface` for the complete rejected-syntax list.
#![deny(missing_docs)]
use std::collections::BTreeMap;
use std::error::Error;
use std::io;
use wip_host::{
CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, ObjectProvider,
PublishedObject, RegisteredInterface, Target, Validator, WipObject,
};
use wip_interface as wip;
use wip_interface::WipType;
use wip_protocol::{ProtocolError, ProtocolErrorCode, Value};
const OBJECT_PATH: &str = "/article-service";
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
const CATALOG_VALIDATOR: &[u8] = b"catalog-v1";
/// Publication visibility represented as a protocol enum string.
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub enum Visibility {
/// Visible to every authorized caller.
Public,
/// Visible only inside the application domain.
Private,
}
/// An application-owned request DTO mapped to a protocol record.
#[derive(Debug, Clone, PartialEq, wip::WipType)]
pub struct PublishRequest {
/// Title of the article to publish.
pub title: String,
/// Optional article body; omission is distinct from JSON null.
pub body: Option<String>,
/// Canonical Worldspace path to the source entry.
pub source: wip::Entry,
/// Application enum represented by a schema-neutral string.
pub visibility: Visibility,
/// Schema-opaque JSON represented by ordinary protocol values.
pub metadata: wip::JsonValue,
}
/// The application domain result of a publication attempt.
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub enum PublishOutcome {
/// Publication succeeded and returned its application identifier.
Published(String),
/// An application policy rejected the publication.
Rejected(String),
}
/// Article publication operations.
#[wip::interface]
pub trait Catalog {
/// Publishes an article or reports an application-domain rejection.
///
/// # Returns
/// The typed application outcome.
fn publish(
&self,
/// Application publication request.
request: PublishRequest,
) -> wip::OperationResult<PublishOutcome>;
/// Returns the catalog-qualified status.
fn status(&self) -> wip::OperationResult<String>;
/// Demonstrates a host/backend failure rather than a domain result.
fn backend_status(&self) -> wip::OperationResult<String>;
/// Demonstrates denial after every structural precondition succeeds.
fn restricted_status(&self) -> wip::OperationResult<String>;
}
/// Audit operations exposed by the same object.
#[wip::interface]
pub trait Audit {
/// Returns the audit-qualified status.
fn status(&self) -> wip::OperationResult<String>;
}
/// A registered but deliberately unexposed interface.
#[wip::interface]
pub trait Diagnostics {
/// Returns host-private diagnostics when explicitly exposed.
fn diagnostics(&self) -> wip::OperationResult<String>;
}
/// Observable outcomes from the complete public-API scenario.
#[derive(Debug, Clone, PartialEq)]
pub struct DemoReport {
/// Path-segment name from the canonical object projection.
pub object_name: String,
/// Optional object identity, distinct from the resolution path.
pub object_reference: Option<String>,
/// Optional object-state validator.
pub object_validator: Option<Vec<u8>>,
/// Opaque interface references published by the object, in exposure order.
pub published_interfaces: Vec<String>,
/// Opaque reference assigned to the catalog descriptor.
pub catalog_interface_reference: String,
/// Opaque reference assigned to the audit descriptor.
pub audit_interface_reference: String,
/// Descriptor format observed separately for the catalog reference.
pub catalog_descriptor_format: String,
/// Descriptor format observed separately for the audit reference.
pub audit_descriptor_format: String,
/// Typed successful application result decoded from a validated [`Value`].
pub published: PublishOutcome,
/// Typed application-domain rejection decoded from a validated [`Value`].
pub rejected: PublishOutcome,
/// Result of the catalog-qualified `status` operation.
pub catalog_status: String,
/// Result of the audit-qualified `status` operation.
pub audit_status: String,
/// Stable failure kind produced by the deliberate host/backend failure.
pub host_failure: CallErrorKind,
/// A protocol failure whose operation effects cannot be guaranteed absent.
pub protocol_failure: ProtocolError,
/// Authorization failure after a fully qualified valid request.
pub permission_failure: ProtocolErrorCode,
/// Recovery signal for a stale object validator.
pub validator_failure: ProtocolErrorCode,
/// Failure for a known but unexposed interface reference.
pub interface_mismatch: ProtocolErrorCode,
/// Failure for an unknown interface observation reference.
pub interface_not_found: ProtocolErrorCode,
/// First failure for a request with an invalid path and later invalid fields.
pub precedence_failure: ProtocolErrorCode,
}
/// Runs the complete end-to-end conformance example.
///
/// The provider resolves the current object from a Worldspace path for each
/// call. Interface observations remain separate from object projection, and all
/// successful calls carry independent object and interface validators.
///
/// # Errors
///
/// Returns an error if registration, observation, path resolution, dispatch, or
/// application-value decoding fails unexpectedly.
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
let audit = registry.register(AuditDefinition, None)?;
let diagnostics = registry.register(DiagnosticsDefinition, None)?;
let provider = ApplicationProvider {
catalog: catalog.clone(),
audit: audit.clone(),
};
let object = provider.fetch(OBJECT_PATH)?;
let catalog_observation = registry.fetch_interface(
object
.interfaces
.first()
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
)?;
let audit_observation = registry.fetch_interface(
object
.interfaces
.get(1)
.ok_or_else(|| io::Error::other("audit interface was not published"))?,
)?;
let published = dispatch_publish(
&provider,
catalog.reference(),
PublishRequest {
title: "Public APIs".into(),
body: Some("protocol values in, validated values out".into()),
source: wip::Entry::new("/drafts/public-apis")?,
visibility: Visibility::Public,
metadata: wip::JsonValue::Object(vec![wip::JsonMember {
name: "reviewed".into(),
value: wip::JsonValue::Boolean(true),
}]),
},
)?;
let rejected = dispatch_publish(
&provider,
catalog.reference(),
PublishRequest {
title: String::new(),
body: None,
source: wip::Entry::new("/drafts/untitled")?,
visibility: Visibility::Private,
metadata: wip::JsonValue::Null,
},
)?;
let catalog_status = dispatch_string(
&provider,
request(
OBJECT_PATH,
catalog.reference(),
Some(CATALOG_VALIDATOR),
"status",
BTreeMap::new(),
),
)?;
let audit_status = dispatch_string(
&provider,
request(
OBJECT_PATH,
audit.reference(),
None,
"status",
BTreeMap::new(),
),
)?;
let host_error = provider
.call_operation(request(
OBJECT_PATH,
catalog.reference(),
Some(CATALOG_VALIDATOR),
"backend_status",
BTreeMap::new(),
))
.expect_err("backend_status must model a host failure");
ensure_kind(&host_error, CallErrorKind::Implementation)?;
let protocol_failure = host_error.protocol_error();
let permission_failure = provider
.call_operation(request(
OBJECT_PATH,
catalog.reference(),
Some(CATALOG_VALIDATOR),
"restricted_status",
BTreeMap::new(),
))
.expect_err("restricted_status must be denied")
.protocol_error()
.code;
let validator_failure = provider
.call_operation(CallRequest {
target: Target {
path: OBJECT_PATH.into(),
validator: Some(b"stale-object".to_vec()),
},
interface: InterfaceTarget {
reference: diagnostics.reference().as_str().to_owned(),
validator: Some(b"stale-interface".to_vec()),
},
operation: "missing".into(),
arguments: BTreeMap::from([("unknown".into(), Value::Unit)]),
})
.expect_err("the stale object validator must fail first")
.protocol_error()
.code;
let interface_mismatch = provider
.call_operation(request(
OBJECT_PATH,
diagnostics.reference(),
None,
"diagnostics",
BTreeMap::new(),
))
.expect_err("registered but unexposed interface must not dispatch")
.protocol_error()
.code;
let interface_not_found = registry
.fetch_interface("missing-interface")
.expect_err("unknown interface references must not resolve")
.code;
let precedence_failure = provider
.call_operation(CallRequest {
target: Target {
path: "relative/path".into(),
validator: Some(b"stale-object".to_vec()),
},
interface: InterfaceTarget {
reference: diagnostics.reference().as_str().to_owned(),
validator: Some(b"stale-interface".to_vec()),
},
operation: "missing".into(),
arguments: BTreeMap::from([("unknown".into(), Value::Unit)]),
})
.expect_err("invalid path must have canonical precedence")
.protocol_error()
.code;
Ok(DemoReport {
object_name: object.name,
object_reference: object.r#ref,
object_validator: object.validator,
published_interfaces: object.interfaces,
catalog_interface_reference: catalog.reference().as_str().to_owned(),
audit_interface_reference: audit.reference().as_str().to_owned(),
catalog_descriptor_format: catalog_observation.descriptor.format,
audit_descriptor_format: audit_observation.descriptor.format,
published,
rejected,
catalog_status,
audit_status,
host_failure: host_error.kind(),
protocol_failure,
permission_failure,
validator_failure,
interface_mismatch,
interface_not_found,
precedence_failure,
})
}
fn ensure_kind(error: &wip_host::CallError, expected: CallErrorKind) -> Result<(), io::Error> {
if error.kind() == expected {
Ok(())
} else {
Err(io::Error::other(format!(
"unexpected host failure kind: {:?}",
error.kind()
)))
}
}
fn dispatch_publish(
provider: &ApplicationProvider,
catalog: &InterfaceRef,
request_value: PublishRequest,
) -> Result<PublishOutcome, Box<dyn Error>> {
let encoded_request = request_value.encode()?;
let result = provider.call_operation(request(
OBJECT_PATH,
catalog,
Some(CATALOG_VALIDATOR),
"publish",
BTreeMap::from([("request".into(), encoded_request)]),
))?;
Ok(PublishOutcome::decode(result.result)?)
}
fn dispatch_string(
provider: &ApplicationProvider,
request: CallRequest,
) -> Result<String, Box<dyn Error>> {
let result = provider.call_operation(request)?;
Ok(String::decode(result.result)?)
}
fn request(
path: &str,
interface: &InterfaceRef,
interface_validator: Option<&[u8]>,
operation: &str,
arguments: BTreeMap<String, Value>,
) -> CallRequest {
CallRequest {
target: Target {
path: path.into(),
validator: Some(OBJECT_VALIDATOR.to_vec()),
},
interface: InterfaceTarget {
reference: interface.as_str().to_owned(),
validator: interface_validator.map(<[u8]>::to_vec),
},
operation: operation.into(),
arguments,
}
}
struct ApplicationProvider {
catalog: RegisteredInterface<CatalogDefinition>,
audit: RegisteredInterface<AuditDefinition>,
}
impl ObjectProvider for ApplicationProvider {
type Object = ApplicationService;
fn with_object<R>(
&self,
path: &str,
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
) -> Option<R> {
if path != OBJECT_PATH {
return None;
}
let publication = PublishedObject::new(ApplicationService::new())
.expose(&self.catalog)
.expect("ApplicationService implements Catalog")
.expose(&self.audit)
.expect("ApplicationService implements Audit");
Some(operation(&publication))
}
fn authorize(
&self,
_: &str,
_: &ApplicationService,
_: &InterfaceRef,
operation: &str,
) -> bool {
operation != "restricted_status"
}
}
struct ApplicationService {
object_validator: Validator,
backend_connection: String,
}
impl ApplicationService {
fn new() -> Self {
Self {
object_validator: Validator::new(OBJECT_VALIDATOR),
backend_connection: "private://article-database".into(),
}
}
}
impl WipObject for ApplicationService {
fn name(&self) -> &str {
"article-service"
}
fn description(&self) -> Option<&str> {
Some("Article publication service")
}
fn reference(&self) -> Option<&str> {
Some("article-service-instance")
}
fn validator(&self) -> Option<&Validator> {
Some(&self.object_validator)
}
fn requires_validator(&self, _: &InterfaceRef, _: &str) -> bool {
true
}
}
impl Catalog for ApplicationService {
fn publish(&self, request: PublishRequest) -> wip::OperationResult<PublishOutcome> {
if request.title.trim().is_empty() {
Ok(PublishOutcome::Rejected("a title is required".into()))
} else {
Ok(PublishOutcome::Published("article-1".into()))
}
}
fn status(&self) -> wip::OperationResult<String> {
Ok("catalog-ready".into())
}
fn backend_status(&self) -> wip::OperationResult<String> {
if self.backend_connection.starts_with("private://") {
Err(wip::HostError::new("backend unavailable"))
} else {
Ok("backend-ready".into())
}
}
fn restricted_status(&self) -> wip::OperationResult<String> {
Ok("authorization hook should prevent this call".into())
}
}
impl Audit for ApplicationService {
fn status(&self) -> wip::OperationResult<String> {
Ok("audit-ready".into())
}
}
impl Diagnostics for ApplicationService {
fn diagnostics(&self) -> wip::OperationResult<String> {
Ok("host-private".into())
}
}
+14
View File
@@ -0,0 +1,14 @@
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(())
}
@@ -0,0 +1,239 @@
use std::collections::BTreeMap;
use wip_host::CallErrorKind;
use wip_interface::{Entry, JsonMember, JsonValue, WipType};
use wip_protocol::{INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, ProtocolErrorCode, Value};
use wip_public_api_example::{CatalogDefinition, PublishOutcome, PublishRequest, Visibility, run};
#[test]
fn public_crates_support_the_complete_qualified_end_to_end_scenario() {
let report = run().unwrap();
assert_eq!(report.object_name, "article-service");
assert_eq!(
report.object_reference.as_deref(),
Some("article-service-instance")
);
assert_eq!(
report.object_validator.as_deref(),
Some(b"object-v1".as_slice())
);
assert_eq!(
report.published_interfaces,
[
report.catalog_interface_reference.clone(),
report.audit_interface_reference.clone(),
],
"interfaces must retain explicit publication order"
);
assert_ne!(
report.catalog_interface_reference, report.audit_interface_reference,
"interfaces form an ordered set of distinct opaque references"
);
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!(
report.published,
PublishOutcome::Published("article-1".into())
);
assert_eq!(
report.rejected,
PublishOutcome::Rejected("a title is required".into())
);
assert_eq!(report.catalog_status, "catalog-ready");
assert_eq!(report.audit_status, "audit-ready");
}
#[test]
fn domain_host_permission_and_validator_failures_remain_distinct() {
let report = run().unwrap();
assert!(matches!(report.rejected, PublishOutcome::Rejected(_)));
assert_eq!(report.host_failure, CallErrorKind::Implementation);
assert_eq!(
report.protocol_failure.code,
ProtocolErrorCode::OperationOutcomeUnknown
);
assert_eq!(
report.permission_failure,
ProtocolErrorCode::PermissionDenied
);
assert_eq!(
report.validator_failure,
ProtocolErrorCode::ValidatorMismatch,
"validator recovery signals must not collapse to InvalidRequest"
);
}
#[test]
fn interface_mismatch_is_not_interface_not_found() {
let report = run().unwrap();
assert_eq!(
report.interface_mismatch,
ProtocolErrorCode::InterfaceMismatch
);
assert_eq!(
report.interface_not_found,
ProtocolErrorCode::InterfaceNotFound
);
assert_ne!(report.interface_mismatch, report.interface_not_found);
}
#[test]
fn invalid_path_keeps_canonical_failure_precedence() {
let report = run().unwrap();
assert_eq!(report.precedence_failure, ProtocolErrorCode::InvalidRequest);
assert_ne!(report.precedence_failure, report.validator_failure);
assert_ne!(report.precedence_failure, report.interface_mismatch);
}
#[test]
fn object_fields_keep_path_context_and_optional_ref_separate() {
// Listing every field makes this test fail to compile if a mandatory
// ObjectId is reintroduced into the canonical object DTO.
let object = Object {
name: "article".into(),
description: None,
interfaces: vec!["catalog-ref".into(), "audit-ref".into()],
r#ref: None,
validator: None,
};
object.validate_at_path("/library/article").unwrap();
assert_eq!(object.name, "article");
assert_eq!(object.r#ref, None);
assert_eq!(object.interfaces, ["catalog-ref", "audit-ref"]);
}
#[test]
fn interface_reference_is_observed_separately_from_descriptor_fields() {
// An exact destructuring pattern is compile-time coverage that the
// descriptor has no trait-name or opaque-reference `name` field.
let InterfaceDescriptor {
format,
documentation,
types,
operations,
} = CatalogDefinition::descriptor();
assert_eq!(format, INTERFACE_FORMAT_V1);
assert!(documentation.is_some());
assert!(!types.is_empty());
assert_eq!(
operations
.iter()
.map(|operation| operation.name.as_str())
.collect::<Vec<_>>(),
["publish", "status", "backend_status", "restricted_status"]
);
}
#[test]
fn application_record_enum_union_entry_and_json_use_schema_neutral_values() {
let request = PublishRequest {
title: "Canonical values".into(),
body: None,
source: Entry::new("/drafts/canonical-values").unwrap(),
visibility: Visibility::Public,
metadata: JsonValue::Object(vec![
JsonMember {
name: "tags".into(),
value: JsonValue::Array(vec![JsonValue::String("wip".into()), JsonValue::Null]),
},
JsonMember {
name: "ready".into(),
value: JsonValue::Boolean(true),
},
]),
};
let encoded = request.encode().unwrap();
assert_schema_neutral(&encoded);
let Value::Record(fields) = encoded else {
panic!("derived DTO record must use Value::Record")
};
assert_eq!(
fields.get("title"),
Some(&Value::String("Canonical values".into()))
);
assert!(!fields.contains_key("body"));
assert_eq!(
fields.get("source"),
Some(&Value::String("/drafts/canonical-values".into()))
);
assert_eq!(
fields.get("visibility"),
Some(&Value::String("Public".into()))
);
assert_eq!(
fields.get("metadata"),
Some(&Value::Record(BTreeMap::from([
("ready".into(), Value::Boolean(true)),
(
"tags".into(),
Value::List(vec![Value::String("wip".into()), Value::Unit])
),
])))
);
let outcome = PublishOutcome::Rejected("policy".into()).encode().unwrap();
assert_schema_neutral(&outcome);
assert_eq!(
outcome,
Value::Record(BTreeMap::from([
("$case".into(), Value::String("Rejected".into())),
("value".into(), Value::String("policy".into())),
]))
);
}
#[test]
fn entry_is_a_validated_canonical_absolute_path_string() {
let entry = Entry::new("/articles/one").unwrap();
assert_eq!(
entry.encode().unwrap(),
Value::String("/articles/one".into())
);
for invalid in [
"articles/one",
"/articles/",
"/articles//one",
"/articles/./one",
"/articles/../one",
] {
assert!(
Entry::new(invalid).is_err(),
"accepted invalid path {invalid}"
);
assert!(
Entry::decode(Value::String(invalid.into())).is_err(),
"decoded invalid path {invalid}"
);
}
}
fn assert_schema_neutral(value: &Value) {
// This exhaustive match is a compile-time regression guard: reintroducing
// semantic Entry/Enum/Union/Json variants makes the consumer non-exhaustive.
match value {
Value::Unit
| Value::Boolean(_)
| Value::Integer(_)
| Value::Number(_)
| Value::String(_)
| Value::Bytes(_) => {}
Value::Record(fields) => {
for value in fields.values() {
assert_schema_neutral(value);
}
}
Value::List(values) => {
for value in values {
assert_schema_neutral(value);
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
fn main() {
println!("Hello, world!");
}