From 0705101c97ef61ad607e221fa97bade890d6c730 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 29 Sep 2026 00:04:11 +0900 Subject: [PATCH] feat(example): add public API end-to-end flow --- Cargo.lock | 4 +- Cargo.toml | 7 +- README.md | 136 ++++++++-- src/lib.rs | 627 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 15 +- tests/end_to_end.rs | 27 ++ 6 files changed, 791 insertions(+), 25 deletions(-) create mode 100644 src/lib.rs create mode 100644 tests/end_to_end.rs diff --git a/Cargo.lock b/Cargo.lock index c755088..7b40257 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 9e5498a..8e01e49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index 34da9d9..629ebf6 100644 --- a/README.md +++ b/README.md @@ -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` return; +- `()` only as the application return when no value is produced; and +- direct `Option` 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` | entry | | `Vec` | 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` 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` 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`. 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. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a13758c --- /dev/null +++ b/src/lib.rs @@ -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>(()) +//! ``` + +#![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, +} + +/// 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; + + /// Returns the catalog-qualified status. + /// + /// # Returns + /// A catalog status label. + fn status(&self) -> wip::OperationResult; + + /// 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; +} + +/// 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; +} + +/// 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; +} + +/// 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, + /// 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> { + 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, + catalog: &InterfaceRef, + request_value: PublishRequest, +) -> Result> { + 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, + request: CallRequest, + context: &str, +) -> Result> { + 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, + operation: &str, + arguments: Vec, +) -> 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 { + 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 { + Ok("catalog-ready".into()) + } + + fn backend_status(&self) -> wip::OperationResult { + 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 { + Ok("audit-ready".into()) + } +} + +impl Diagnostics for ApplicationService { + fn diagnostics(&self) -> wip::OperationResult { + 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) {} + + fn encode(self) -> Result { + Ok(Value::Integer(7)) + } + + fn decode(_: Value) -> Result { + 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; + } + + impl BrokenResult for ApplicationService { + fn broken_result(&self) -> wip::OperationResult { + Ok(InvalidValue) + } + } + + struct Fixture { + publication: PublishedObject, + 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, + ); + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..ad5d423 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,14 @@ -fn main() { - println!("Hello, world!"); +use std::error::Error; + +fn main() -> Result<(), Box> { + 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(()) } diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs new file mode 100644 index 0000000..546a6c6 --- /dev/null +++ b/tests/end_to_end.rs @@ -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 + )); +}