feat(example): add public API end-to-end flow
This commit is contained in:
Generated
+3
-1
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -9,9 +9,14 @@ members = [
|
|||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "wip-rs"
|
name = "wip-public-api-example"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
description = "End-to-end example of the public WIP Rust APIs"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
wip-host = { path = "crates/wip-host" }
|
||||||
|
wip-interface = { path = "crates/wip-interface" }
|
||||||
wip-protocol = { path = "crates/wip-protocol" }
|
wip-protocol = { path = "crates/wip-protocol" }
|
||||||
|
|||||||
@@ -2,6 +2,65 @@
|
|||||||
|
|
||||||
Rust building blocks for the Web Interface Protocol (WIP).
|
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
|
## Interface authoring
|
||||||
|
|
||||||
Applications depend on `wip-interface`, normally imported as `wip`:
|
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;
|
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 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 |
|
| Rust | Protocol |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -45,27 +123,21 @@ dispatch for any `T: Messenger`.
|
|||||||
| `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 |
|
||||||
| `wip::Entry<K, V>` | entry |
|
| `wip::Entry<K, V>` | entry |
|
||||||
| `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 |
|
||||||
| all-newtype `#[derive(wip::WipType)]` enum | named tagged union |
|
| all-newtype `#[derive(wip::WipType)]` enum | named tagged union |
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
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
|
||||||
@@ -84,16 +156,38 @@ let protocol_object = published.protocol_object();
|
|||||||
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; 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
|
Dispatch is qualified by interface reference and operation name. The host
|
||||||
current object identity and validator policy, current interface membership,
|
checks current object identity and validator policy, current interface
|
||||||
interface validator, descriptor operation, arguments, and result while retaining
|
membership, interface validator, descriptor operation, arguments, and result
|
||||||
the selected object and registered artifact. Host-private fields and backend
|
while retaining the selected object and registered artifact. Host-private
|
||||||
handles are never projected by `WipObject`.
|
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,
|
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 constructing and
|
||||||
dispatching the request-scoped `PublishedObject`, and retain it until dispatch
|
dispatching the request-scoped `PublishedObject`, and retain it until dispatch
|
||||||
returns. The runtime never validates one object and then resolves a second
|
returns. The runtime never validates one object and then resolves a second
|
||||||
snapshot, but it cannot make an arbitrary external backend atomic by itself.
|
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.
|
||||||
|
|||||||
+627
@@ -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
@@ -1,3 +1,14 @@
|
|||||||
fn main() {
|
use std::error::Error;
|
||||||
println!("Hello, world!");
|
|
||||||
|
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,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
|
||||||
|
));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user