Compare commits

..
18 changed files with 921 additions and 42 deletions
Generated
+3 -1
View File
@@ -270,9 +270,11 @@ name = "wip-protocol"
version = "0.1.0"
[[package]]
name = "wip-rs"
name = "wip-public-api-example"
version = "0.1.0"
dependencies = [
"wip-host",
"wip-interface",
"wip-protocol",
]
+6 -1
View File
@@ -9,9 +9,14 @@ members = [
resolver = "3"
[package]
name = "wip-rs"
name = "wip-public-api-example"
version = "0.1.0"
edition = "2024"
description = "End-to-end example of the public WIP Rust APIs"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
wip-host = { path = "crates/wip-host" }
wip-interface = { path = "crates/wip-interface" }
wip-protocol = { path = "crates/wip-protocol" }
+115 -21
View File
@@ -2,6 +2,65 @@
Rust building blocks for the Web Interface Protocol (WIP).
The repository currently provides a transport-independent protocol model, a
Rust interface-authoring facade, and a request-scoped host runtime. HTTP and
other transport bindings are intentionally outside the supported slice.
## Crate graph and responsibilities
```text
wip-public-api-example (workspace-only executable, integration tests, doctest)
├── wip-protocol logical declarations, values, calls, and validation
├── wip-interface application DTO codecs and interface authoring facade
│ ├── wip-protocol
│ └── wip-interface-macros proc-macro implementation detail
└── wip-host descriptor registry, publication, and qualified dispatch
├── wip-interface
└── wip-protocol
```
| Crate | Responsibility | Does not own |
| --- | --- | --- |
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, and interface-qualified dispatch against one host-owned instance. | Backend atomicity, global object lookup, or network serving. |
| `wip-public-api-example` | An independently compiled consumer of only the three public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
The boundary is intentional: protocol types describe what can cross a
transport; application DTOs opt into that model with `WipType`; a host-private
implementation owns credentials, handles, and backend state; and a future
transport maps its own routes/envelopes to `OperationCall`/`OperationResult`
without placing transport-specific types in descriptors or application traits.
## End-to-end example
[`src/lib.rs`](src/lib.rs) is the complete public-API example and
[`src/main.rs`](src/main.rs) runs it. It demonstrates all of the following:
1. public application DTOs derived as `WipType` records and unions;
2. two public traits declared with `#[wip::interface]`;
3. a private host struct implementing both traits;
4. explicit publication of both interfaces on one object;
5. registry observation and validation of generated descriptors;
6. calls made with interface references, operation names, and `Value` arguments;
7. validated result values decoded back to the typed application result;
8. independent dispatch of the same `status` operation name on both interfaces;
9. a domain rejection returned as data and a backend failure returned as a host
error.
Run it inside the development shell:
```console
nix develop
cargo run --package wip-public-api-example
```
The normal integration test is in [`tests/end_to_end.rs`](tests/end_to_end.rs).
Unit tests beside the example additionally cover every host dispatch failure,
invalid arguments and results, interface membership, validators, and
same-named operations.
## Interface authoring
Applications depend on `wip-interface`, normally imported as `wip`:
@@ -30,12 +89,31 @@ pub trait Messenger {
```
The `interface` attribute and `WipType` derive are re-exported by the facade;
users do not import `wip-interface-macros` directly. The generated
`MessengerDefinition` constructs an `InterfaceDescriptor`, decodes named
arguments, encodes results, validates both boundaries, and provides typed
dispatch for any `T: Messenger`.
users do not import `wip-interface-macros` or generated private support symbols.
The generated `MessengerDefinition` constructs an `InterfaceDescriptor`,
decodes named arguments, encodes results, validates both boundaries, and
provides typed dispatch for any `T: Messenger`.
### Type mapping
### Supported Rust syntax
A supported interface is a public, non-generic trait containing methods with:
- exactly an immutable `&self` receiver;
- simple named, owned parameters;
- an explicit `wip::OperationResult<T>` return;
- `()` only as the application return when no value is produced; and
- direct `Option<T>` only for an omittable parameter.
`WipType` derives support named-field structs, all-unit enums, and enums whose
variants each contain exactly one unnamed payload. The macros reject private,
generic, inherited, unsafe, auto, or associated-item traits; const, async,
unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods;
borrowed/custom-lifetime values; tuple/unit/generic structs; Rust unions;
mixed/named-field enum variants; explicit discriminants; nested `Option`; and
`Option` in returns, collections, entries, or union payloads. These diagnostics
are compile-fail tested under `crates/wip-interface/tests/ui`.
### Rust type mapping
| Rust | Protocol |
| --- | --- |
@@ -45,27 +123,21 @@ dispatch for any `T: Messenger`.
| `f32`, `f64` | finite number |
| `String` | string |
| `wip::Bytes` | bytes |
| `wip::wip_protocol::JsonValue` | JSON |
| `wip::JsonValue` | JSON |
| `wip::Entry<K, V>` | entry |
| `Vec<T>` | list |
| named-field `#[derive(wip::WipType)]` struct | named record |
| all-unit `#[derive(wip::WipType)]` enum | named enum |
| all-newtype `#[derive(wip::WipType)]` enum | named tagged union |
`Option<T>` is accepted only for a direct DTO field or operation parameter and
means that the named value may be omitted. It is not protocol null. Domain
failures are modeled in the application result (usually a derived union) and
returned through `Ok`; `wip::HostError` is only for host/runtime failures.
`Option<T>` means that a direct DTO field or operation parameter may be
omitted; it is not protocol null. JSON null remains available inside
`wip::JsonValue`.
Rust doc comments become descriptor metadata. For an operation, a `# Returns`
section documents the return declaration; text before that section documents
the operation itself.
The supported interface slice is deliberately non-generic: public traits,
`&self`, named parameters, and `OperationResult<T>`. Generic traits or methods,
associated items, custom lifetimes, inheritance, default method bodies, and
other receiver forms are rejected at their source spans.
## Host publication and dispatch
Applications use `wip-host` to register generated definitions and explicitly
@@ -84,16 +156,38 @@ let protocol_object = published.protocol_object();
interface trait. Implementing a trait does not expose it automatically, empty
interface sets are valid, insertion order is preserved, and duplicate
references are rejected. Registry references are opaque; descriptor names and
Rust type names are not used as wire identity.
Rust type names are not used as independent wire identity.
Dispatch is qualified by interface reference and operation name. The host checks
current object identity and validator policy, current interface membership,
interface validator, descriptor operation, arguments, and result while retaining
the selected object and registered artifact. Host-private fields and backend
handles are never projected by `WipObject`.
Dispatch is qualified by interface reference and operation name. The host
checks current object identity and validator policy, current interface
membership, interface validator, descriptor operation, arguments, and result
while retaining the selected object and registered artifact. Host-private
fields and backend handles are never projected by `WipObject`.
Application domain failures belong in a derived result union and are returned
through `Ok`. `wip::HostError` is reserved for runtime/backend failures. Invalid
arguments are rejected before invocation; malformed encoded results are
rejected as internal host failures and never returned as application data.
Providers remain responsible for backend atomicity. Enter a transaction, lock,
stable-handle scope, or conditional-update primitive before constructing and
dispatching the request-scoped `PublishedObject`, and retain it until dispatch
returns. The runtime never validates one object and then resolves a second
snapshot, but it cannot make an arbitrary external backend atomic by itself.
## Validation
Enter the dev shell and run the acceptance commands exactly:
```console
nix develop
cargo fmt --all -- --check
cargo check --workspace --all-targets
cargo test --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
```
`cargo test --workspace --all-targets` includes the public example integration
test, runtime failure tests, and `trybuild` compile-fail suites. Run
`cargo test --workspace --doc` when validating the checked rustdoc examples
separately.
+12
View File
@@ -208,6 +208,18 @@ impl InterfaceRegistry {
})
}
/// Resolves a transport-provided opaque reference string in this registry.
///
/// The returned value is registry-scoped and suitable for observation or a
/// qualified [`CallRequest`]. Unknown or foreign references return `None`;
/// constructing arbitrary `InterfaceRef` values is intentionally not part of
/// the public API.
#[must_use]
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
let reference = InterfaceRef(reference.to_owned());
self.artifacts.contains_key(&reference).then_some(reference)
}
/// Observes the descriptor and optional validator for an opaque reference.
#[must_use]
pub fn observe(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
+10
View File
@@ -163,6 +163,11 @@ fn registry_exposes_opaque_validated_descriptor_observations() {
let reader = registry.register(ReaderDefinition, None).unwrap();
assert_ne!(reader.reference().as_str(), "Reader");
assert_eq!(
registry.resolve(reader.reference().as_str()).as_ref(),
Some(reader.reference())
);
assert!(registry.resolve("wip-interface:unknown:0").is_none());
let observation = registry.observe(reader.reference()).unwrap();
assert_eq!(observation.reference, *reader.reference());
assert_eq!(observation.descriptor.name, reader.reference().as_str());
@@ -174,6 +179,11 @@ fn registry_exposes_opaque_validated_descriptor_observations() {
let mut other_registry = InterfaceRegistry::new();
let other_reader = other_registry.register(ReaderDefinition, None).unwrap();
assert_ne!(reader.reference(), other_reader.reference());
assert!(
registry
.resolve(other_reader.reference().as_str())
.is_none()
);
}
#[test]
+35 -9
View File
@@ -1,4 +1,10 @@
//! Procedural macros backing the public `wip-interface` authoring facade.
//!
//! This is an implementation crate. Applications should depend on
//! `wip-interface`, which re-exports both macros alongside the traits and types
//! used by their generated code.
#![deny(missing_docs)]
use std::collections::HashSet;
@@ -470,13 +476,18 @@ fn parse_operation_result(output: &ReturnType) -> syn::Result<Type> {
_ => None,
});
let result = types.next().cloned();
if arguments.args.len() != 1 || result.is_none() || types.next().is_some() {
if arguments.args.len() != 1 || types.next().is_some() {
return Err(Error::new_spanned(
arguments,
"`OperationResult` must have exactly one application result type",
));
}
let result = result.expect("checked above");
let result = result.ok_or_else(|| {
Error::new_spanned(
arguments,
"`OperationResult` must have exactly one application result type",
)
})?;
if direct_option(&result)?.is_some() || contains_option(&result) {
return Err(Error::new_spanned(
result,
@@ -490,8 +501,13 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
let arguments_ident = &operation.arguments_ident;
let fields = operation.parameters.iter().map(|parameter| {
let ident = &parameter.ident;
let name = &parameter.name;
let ty = parameter_rust_type(parameter);
quote!(pub #ident: #ty)
let documentation = format!("Decoded `{name}` operation argument.");
quote!(
#[doc = #documentation]
pub #ident: #ty
)
});
let decoders = operation.parameters.iter().map(|parameter| {
let ident = &parameter.ident;
@@ -698,7 +714,12 @@ fn derive_record(
let mut parsed_fields = Vec::new();
let mut names = HashSet::new();
for field in fields {
let field_ident = field.ident.clone().expect("named fields have identifiers");
let Some(field_ident) = field.ident.clone() else {
return Err(Error::new_spanned(
field,
"`WipType` record fields must be named",
));
};
let name = source_name(&field_ident);
if !names.insert(name.clone()) {
return Err(Error::new(
@@ -984,12 +1005,17 @@ fn derive_union_enum(
let mut variants = Vec::new();
for variant in &data.variants {
let Fields::Unnamed(fields) = &variant.fields else {
unreachable!("enum shape was checked before union generation");
return Err(Error::new_spanned(
variant,
"a `WipType` union variant must contain exactly one unnamed field",
));
};
let Some(field) = fields.unnamed.first() else {
return Err(Error::new_spanned(
fields,
"a `WipType` union variant must contain exactly one unnamed field",
));
};
let field = fields
.unnamed
.first()
.expect("union variants have one field");
if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) {
return Err(Error::new_spanned(
&field.ty,
+16 -8
View File
@@ -48,6 +48,21 @@
//! an application return enum/union and is returned as `Ok(value)`.
//! [`HostError`] is reserved for host/runtime failure, so the required
//! [`OperationResult`] convention keeps those two failure classes distinct.
//!
//! # Supported interface syntax
//!
//! An interface is a public, non-generic trait whose operations use exactly
//! `&self`, simple named owned parameters, and an explicit
//! `OperationResult<T>`. `WipType` derives support named-field structs,
//! all-unit enums, and all-single-payload enums.
//!
//! The macros reject trait inheritance and associated items; const, async,
//! unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods;
//! borrowed values and custom lifetimes; tuple, unit, or generic structs;
//! mixed or named-field enum variants; explicit discriminants; Rust unions;
//! and `Option` anywhere except a direct DTO field or operation parameter.
//! Unsupported inputs produce source-located compile errors rather than
//! generating a partial interface.
#![deny(missing_docs)]
@@ -619,7 +634,6 @@ pub mod __private {
/// Decoder for a protocol record or operation's named argument collection.
pub struct RecordDecoder {
fields: Vec<NamedValue>,
seen: HashSet<String>,
}
impl RecordDecoder {
@@ -633,7 +647,7 @@ pub mod __private {
.at(field.name.clone()));
}
}
Ok(Self { fields, seen })
Ok(Self { fields })
}
/// Decodes a required field.
@@ -666,11 +680,5 @@ pub mod __private {
let index = self.fields.iter().position(|field| field.name == name)?;
Some(self.fields.remove(index).value)
}
/// Returns the set of names supplied to this decoder.
#[allow(dead_code)]
pub fn supplied_names(&self) -> &HashSet<String> {
&self.seen
}
}
}
@@ -0,0 +1,8 @@
use wip_interface::interface;
#[interface]
pub trait AsyncOperation {
async fn status(&self) -> wip_interface::OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,5 @@
error: async operations are not supported
--> tests/ui/async_operation.rs:5:5
|
5 | async fn status(&self) -> wip_interface::OperationResult<String>;
| ^^^^^
@@ -0,0 +1,10 @@
use wip_interface::interface;
#[interface]
pub trait DefaultMethod {
fn status(&self) -> wip_interface::OperationResult<String> {
Ok(String::new())
}
}
fn main() {}
@@ -0,0 +1,8 @@
error: WIP operation methods must not have a body
--> tests/ui/default_method.rs:5:64
|
5 | fn status(&self) -> wip_interface::OperationResult<String> {
| ________________________________________________________________^
6 | | Ok(String::new())
7 | | }
| |_____^
@@ -0,0 +1,8 @@
use wip_interface::WipType;
#[derive(WipType)]
struct GenericDto<T> {
value: T,
}
fn main() {}
@@ -0,0 +1,5 @@
error: generic DTOs are not supported by `WipType`
--> tests/ui/generic_dto.rs:4:18
|
4 | struct GenericDto<T> {
| ^^^
@@ -0,0 +1,8 @@
use wip_interface::WipType;
#[derive(WipType)]
struct NestedOption {
values: Vec<Option<String>>,
}
fn main() {}
@@ -0,0 +1,5 @@
error: `Option<T>` must appear directly as the DTO field type
--> tests/ui/nested_option.rs:5:13
|
5 | values: Vec<Option<String>>,
| ^^^^^^^^^^^^^^^^^^^
+627
View File
@@ -0,0 +1,627 @@
//! End-to-end use of the public WIP protocol, interface, and host APIs.
//!
//! This workspace package is deliberately separate from the three library
//! crates. It defines application DTOs and interfaces with `wip-interface`,
//! keeps its implementation private, publishes that implementation with
//! `wip-host`, and crosses the call boundary only with `wip-protocol` values.
//!
//! The complete scenario can be run as a normal binary or called directly:
//!
//! ```
//! let report = wip_public_api_example::run()?;
//!
//! assert_eq!(report.published_interfaces.len(), 2);
//! assert_eq!(report.catalog_status, "catalog-ready");
//! assert_eq!(report.audit_status, "audit-ready");
//! assert_eq!(
//! report.host_failure,
//! wip_host::CallErrorKind::Implementation,
//! );
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
#![deny(missing_docs)]
use std::error::Error;
use std::io;
use wip_host::{
CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, PublishedObject,
Validator, WipObject,
};
use wip_interface as wip;
use wip_interface::WipType;
use wip_protocol::{
InterfaceObservation, NamedValue, ObjectId, OperationOutcome, OperationResult, OperationTarget,
};
const OBJECT_ID: &str = "article-service";
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
const CATALOG_VALIDATOR: &[u8] = b"catalog-v1";
/// An application-owned request DTO mapped to a protocol record.
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub struct PublishRequest {
/// Title of the article to publish.
pub title: String,
/// Optional article body; omission is distinct from JSON null.
pub body: Option<String>,
}
/// The application domain result of a publication attempt.
///
/// Rejection is an ordinary typed result. It is intentionally distinct from a
/// host or backend failure, which uses [`wip::HostError`].
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub enum PublishOutcome {
/// Publication succeeded and returned its application identifier.
Published(String),
/// An application policy rejected the publication.
Rejected(String),
}
/// Article publication operations.
#[wip::interface]
pub trait Catalog {
/// Publishes an article or reports an application-domain rejection.
///
/// # Returns
/// The typed application outcome.
fn publish(
&self,
/// Application publication request.
request: PublishRequest,
) -> wip::OperationResult<PublishOutcome>;
/// Returns the catalog-qualified status.
///
/// # Returns
/// A catalog status label.
fn status(&self) -> wip::OperationResult<String>;
/// Demonstrates a host/backend failure rather than a domain result.
///
/// # Returns
/// A backend status only when the host can reach it.
fn backend_status(&self) -> wip::OperationResult<String>;
}
/// Audit operations exposed by the same object.
#[wip::interface]
pub trait Audit {
/// Returns the audit-qualified status.
///
/// This deliberately has the same operation name as [`Catalog::status`].
///
/// # Returns
/// An audit status label.
fn status(&self) -> wip::OperationResult<String>;
}
/// A registered but deliberately unexposed interface used to demonstrate that
/// Rust trait implementation does not imply protocol membership.
#[wip::interface]
pub trait Diagnostics {
/// Returns host-private diagnostics when the interface is explicitly exposed.
///
/// # Returns
/// A diagnostic label.
fn diagnostics(&self) -> wip::OperationResult<String>;
}
/// Observable outcomes from the complete public-API scenario.
#[derive(Debug, Clone, PartialEq)]
pub struct DemoReport {
/// Opaque interface references published by the object, in exposure order.
pub published_interfaces: Vec<String>,
/// Descriptor identity observed for the catalog interface.
pub catalog_descriptor: String,
/// Descriptor identity observed for the audit interface.
pub audit_descriptor: String,
/// Typed successful application result decoded from a validated `Value`.
pub published: PublishOutcome,
/// Typed application-domain rejection decoded from a validated `Value`.
pub rejected: PublishOutcome,
/// Result of the catalog-qualified `status` operation.
pub catalog_status: String,
/// Result of the audit-qualified `status` operation.
pub audit_status: String,
/// Stable failure kind produced by the deliberate host/backend failure.
pub host_failure: CallErrorKind,
/// A protocol-level operation result built from the host failure.
pub protocol_failure: OperationResult,
}
/// Runs the complete end-to-end example.
///
/// The function registers and observes generated descriptors, explicitly
/// exposes two interfaces on one host-private object, validates the resulting
/// protocol observation, dispatches interface-qualified calls using named
/// [`wip_protocol::Value`] arguments, decodes validated values back to
/// application types, and keeps a domain rejection distinct from a host
/// failure.
///
/// # Errors
///
/// Returns an error if registration, publication, observation validation,
/// dispatch, or application-value decoding fails unexpectedly.
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
let audit = registry.register(AuditDefinition, None)?;
let _diagnostics = registry.register(DiagnosticsDefinition, None)?;
let publication = PublishedObject::new(ApplicationService::new())
.expose(&catalog)?
.expose(&audit)?;
let object = publication.protocol_object();
// Model a transport/provider that retained only the protocol object's
// opaque strings, then resolved them back through the registry's public API.
let catalog_wire_reference = object
.interfaces
.first()
.ok_or_else(|| io::Error::other("catalog interface was not published"))?;
let audit_wire_reference = object
.interfaces
.get(1)
.ok_or_else(|| io::Error::other("audit interface was not published"))?;
let catalog_reference = registry
.resolve(catalog_wire_reference)
.ok_or_else(|| io::Error::other("published catalog reference did not resolve"))?;
let audit_reference = registry
.resolve(audit_wire_reference)
.ok_or_else(|| io::Error::other("published audit reference did not resolve"))?;
let catalog_observation = registry
.observe(&catalog_reference)
.ok_or_else(|| io::Error::other("registered catalog descriptor was not observable"))?;
let audit_observation = registry
.observe(&audit_reference)
.ok_or_else(|| io::Error::other("registered audit descriptor was not observable"))?;
InterfaceObservation {
object: object.clone(),
descriptors: vec![
catalog_observation.descriptor.clone(),
audit_observation.descriptor.clone(),
],
}
.validate()?;
let published = dispatch_publish(
&publication,
&catalog_observation.reference,
PublishRequest {
title: "Public APIs".into(),
body: Some("protocol values in, validated values out".into()),
},
)?;
let rejected = dispatch_publish(
&publication,
&catalog_observation.reference,
PublishRequest {
title: String::new(),
body: None,
},
)?;
let catalog_status = dispatch_string(
&publication,
request(
catalog_observation.reference.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
),
"catalog status",
)?;
let audit_status = dispatch_string(
&publication,
request(audit_observation.reference.clone(), None, "status", vec![]),
"audit status",
)?;
let host_error = match publication.dispatch(request(
catalog_observation.reference.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"backend_status",
vec![],
)) {
Ok(_) => {
return Err(io::Error::other(
"the deliberately unavailable example backend unexpectedly succeeded",
)
.into());
}
Err(error) => error,
};
if host_error.kind() != CallErrorKind::Implementation {
return Err(io::Error::other(format!(
"unexpected host failure kind: {:?}",
host_error.kind()
))
.into());
}
let protocol_error = host_error.protocol_error();
let protocol_failure = OperationResult {
target: OperationTarget {
object: ObjectId(OBJECT_ID.into()),
interface: catalog_observation.reference.as_str().into(),
operation: "backend_status".into(),
},
outcome: OperationOutcome::Failed(protocol_error),
};
Ok(DemoReport {
published_interfaces: object.interfaces,
catalog_descriptor: catalog_observation.descriptor.name,
audit_descriptor: audit_observation.descriptor.name,
published,
rejected,
catalog_status,
audit_status,
host_failure: host_error.kind(),
protocol_failure,
})
}
fn dispatch_publish(
publication: &PublishedObject<ApplicationService>,
catalog: &InterfaceRef,
request_value: PublishRequest,
) -> Result<PublishOutcome, Box<dyn Error>> {
let encoded_request = request_value.encode()?;
let result = publication.dispatch(request(
catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"publish",
vec![NamedValue {
name: "request".into(),
value: encoded_request,
}],
))?;
let value = result
.value
.ok_or_else(|| io::Error::other("publish returned no application value"))?;
Ok(PublishOutcome::decode(value)?)
}
fn dispatch_string(
publication: &PublishedObject<ApplicationService>,
request: CallRequest,
context: &str,
) -> Result<String, Box<dyn Error>> {
let result = publication.dispatch(request)?;
let value = result
.value
.ok_or_else(|| io::Error::other(format!("{context} returned no value")))?;
Ok(String::decode(value)?)
}
fn request(
interface: InterfaceRef,
interface_validator: Option<Validator>,
operation: &str,
arguments: Vec<NamedValue>,
) -> CallRequest {
CallRequest {
object: ObjectId(OBJECT_ID.into()),
object_validator: Some(Validator::new(OBJECT_VALIDATOR)),
interface: InterfaceTarget {
reference: interface,
validator: interface_validator,
},
operation: operation.into(),
arguments,
}
}
struct ApplicationService {
object_validator: Validator,
backend_connection: String,
}
impl ApplicationService {
fn new() -> Self {
Self {
object_validator: Validator::new(OBJECT_VALIDATOR),
backend_connection: "private://article-database".into(),
}
}
}
impl WipObject for ApplicationService {
fn id(&self) -> ObjectId {
ObjectId(OBJECT_ID.into())
}
fn validator(&self) -> Option<&Validator> {
Some(&self.object_validator)
}
fn requires_validator(&self) -> bool {
true
}
}
impl Catalog for ApplicationService {
fn publish(&self, request: PublishRequest) -> wip::OperationResult<PublishOutcome> {
if request.title.trim().is_empty() {
Ok(PublishOutcome::Rejected("a title is required".into()))
} else {
Ok(PublishOutcome::Published("article-1".into()))
}
}
fn status(&self) -> wip::OperationResult<String> {
Ok("catalog-ready".into())
}
fn backend_status(&self) -> wip::OperationResult<String> {
if self.backend_connection.starts_with("private://") {
Err(wip::HostError::new("backend unavailable"))
} else {
Ok("backend-ready".into())
}
}
}
impl Audit for ApplicationService {
fn status(&self) -> wip::OperationResult<String> {
Ok("audit-ready".into())
}
}
impl Diagnostics for ApplicationService {
fn diagnostics(&self) -> wip::OperationResult<String> {
Ok("host-private".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use wip_host::CallError;
use wip_interface::{CodecError, TypeDeclaration, TypeExpr};
use wip_protocol::{ProtocolErrorCategory, Value};
/// A deliberately inconsistent custom codec used to prove result validation.
pub struct InvalidValue;
impl WipType for InvalidValue {
fn type_expr() -> TypeExpr {
TypeExpr::String
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Integer(7))
}
fn decode(_: Value) -> Result<Self, CodecError> {
Ok(Self)
}
}
/// Test-only interface whose custom codec emits an invalid result.
#[wip::interface]
pub trait BrokenResult {
/// Returns a value that violates its declared protocol type.
fn broken_result(&self) -> wip::OperationResult<InvalidValue>;
}
impl BrokenResult for ApplicationService {
fn broken_result(&self) -> wip::OperationResult<InvalidValue> {
Ok(InvalidValue)
}
}
struct Fixture {
publication: PublishedObject<ApplicationService>,
catalog: InterfaceRef,
audit: InterfaceRef,
diagnostics: InterfaceRef,
}
fn fixture() -> Fixture {
let mut registry = InterfaceRegistry::new();
let catalog = registry
.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))
.unwrap();
let audit = registry.register(AuditDefinition, None).unwrap();
let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap();
let publication = PublishedObject::new(ApplicationService::new())
.expose(&catalog)
.unwrap()
.expose(&audit)
.unwrap();
Fixture {
publication,
catalog: catalog.reference().clone(),
audit: audit.reference().clone(),
diagnostics: diagnostics.reference().clone(),
}
}
fn assert_failure(error: CallError, kind: CallErrorKind, category: ProtocolErrorCategory) {
assert_eq!(error.kind(), kind);
assert_eq!(error.protocol_error().category, category);
}
#[test]
fn interface_membership_and_same_name_operations_are_qualified() {
let fixture = fixture();
let catalog = dispatch_string(
&fixture.publication,
request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
),
"catalog status",
)
.unwrap();
let audit = dispatch_string(
&fixture.publication,
request(fixture.audit.clone(), None, "status", vec![]),
"audit status",
)
.unwrap();
assert_eq!(catalog, "catalog-ready");
assert_eq!(audit, "audit-ready");
let not_exposed = fixture
.publication
.dispatch(request(fixture.diagnostics, None, "diagnostics", vec![]))
.unwrap_err();
assert_failure(
not_exposed,
CallErrorKind::InterfaceMismatch,
ProtocolErrorCategory::InterfaceNotFound,
);
}
#[test]
fn every_host_precondition_and_dispatch_failure_is_typed() {
let fixture = fixture();
let mut wrong_object = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
);
wrong_object.object = ObjectId("other-object".into());
assert_failure(
fixture.publication.dispatch(wrong_object).unwrap_err(),
CallErrorKind::ObjectNotFound,
ProtocolErrorCategory::ObjectNotFound,
);
let mut missing_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
);
missing_object_validator.object_validator = None;
assert_failure(
fixture
.publication
.dispatch(missing_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorRequired,
ProtocolErrorCategory::InvalidRequest,
);
let mut stale_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
);
stale_object_validator.object_validator = Some(Validator::new(b"stale-object"));
assert_failure(
fixture
.publication
.dispatch(stale_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorMismatch,
ProtocolErrorCategory::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(fixture.catalog.clone(), None, "status", vec![]))
.unwrap_err(),
CallErrorKind::InterfaceValidatorRequired,
ProtocolErrorCategory::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(b"stale-interface")),
"status",
vec![],
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorMismatch,
ProtocolErrorCategory::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"missing",
vec![],
))
.unwrap_err(),
CallErrorKind::OperationNotFound,
ProtocolErrorCategory::OperationNotFound,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"publish",
vec![NamedValue {
name: "request".into(),
value: Value::String("not a record".into()),
}],
))
.unwrap_err(),
CallErrorKind::InvalidArguments,
ProtocolErrorCategory::InvalidArguments,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog,
Some(Validator::new(CATALOG_VALIDATOR)),
"backend_status",
vec![],
))
.unwrap_err(),
CallErrorKind::Implementation,
ProtocolErrorCategory::Internal,
);
}
#[test]
fn invalid_implementation_result_never_crosses_the_host_boundary() {
let mut registry = InterfaceRegistry::new();
let broken = registry.register(BrokenResultDefinition, None).unwrap();
let publication = PublishedObject::new(ApplicationService::new())
.expose(&broken)
.unwrap();
let error = publication
.dispatch(request(
broken.reference().clone(),
None,
"broken_result",
vec![],
))
.unwrap_err();
assert_failure(
error,
CallErrorKind::InvalidResult,
ProtocolErrorCategory::Internal,
);
}
}
+13 -2
View File
@@ -1,3 +1,14 @@
fn main() {
println!("Hello, world!");
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let report = wip_public_api_example::run()?;
println!(
"published {} interfaces; catalog={}, audit={}; domain={:?}; host={:?}",
report.published_interfaces.len(),
report.catalog_status,
report.audit_status,
report.rejected,
report.host_failure,
);
Ok(())
}
+27
View File
@@ -0,0 +1,27 @@
use wip_host::CallErrorKind;
use wip_protocol::{OperationOutcome, ProtocolErrorCategory};
use wip_public_api_example::{PublishOutcome, run};
#[test]
fn public_crates_support_the_complete_end_to_end_scenario() {
let report = run().unwrap();
assert_eq!(report.published_interfaces.len(), 2);
assert_eq!(report.catalog_descriptor, report.published_interfaces[0]);
assert_eq!(report.audit_descriptor, report.published_interfaces[1]);
assert_eq!(
report.published,
PublishOutcome::Published("article-1".into())
);
assert_eq!(
report.rejected,
PublishOutcome::Rejected("a title is required".into())
);
assert_eq!(report.catalog_status, "catalog-ready");
assert_eq!(report.audit_status, "audit-ready");
assert_eq!(report.host_failure, CallErrorKind::Implementation);
assert!(matches!(
report.protocol_failure.outcome,
OperationOutcome::Failed(error) if error.category == ProtocolErrorCategory::Internal
));
}