diff --git a/README.md b/README.md index 629ebf6..7884b41 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ wip-public-api-example (workspace-only executable, integration tests, doctest) 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. +transport maps its own routes/envelopes to `CallOperationRequest` and +`CallOperationResponse` without placing transport-specific types in descriptors +or application traits. ## End-to-end example @@ -104,12 +105,13 @@ A supported interface is a public, non-generic trait containing methods with: - `()` 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, +`WipType` derives support named-field structs, all-unit enums, and unions whose +cases may be unit variants or variants containing exactly one unnamed payload. +The macros reject private, generic, inherited, unsafe, auto, or associated-item traits; const, async, unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods; borrowed/custom-lifetime values; tuple/unit/generic structs; Rust unions; -mixed/named-field enum variants; explicit discriminants; nested `Option`; and +named-field/multi-field enum variants; explicit discriminants; nested `Option`; and `Option` in returns, collections, entries, or union payloads. These diagnostics are compile-fail tested under `crates/wip-interface/tests/ui`. @@ -117,23 +119,36 @@ are compile-fail tested under `crates/wip-interface/tests/ui`. | Rust | Protocol | | --- | --- | -| `()` as an operation result | no returned value | +| `()` | unit / `Value::Unit` | | `bool` | boolean | | integer primitives | JavaScript-lossless integer | | `f32`, `f64` | finite number | | `String` | string | | `wip::Bytes` | bytes | -| `wip::JsonValue` | JSON | -| `wip::Entry` | entry | +| `wip::JsonValue` | JSON interpreted over the schema-neutral `Value` tree | +| `wip::Entry` | canonical absolute Worldspace path encoded as a string | | `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 | +| all-unit `#[derive(wip::WipType)]` enum | named enum encoded as a string | +| mixed unit/newtype or all-newtype enum | named union encoded as a discriminator record | `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`. +#### Migration from the pre-canonical value API + +The old generic `wip::Entry` represented an application key/value pair +and is no longer available. Use `wip::Entry::new("/canonical/path")` for a +Worldspace entry path and place any associated application data in a record. +`Entry` encodes as `Value::String` and validates the canonical absolute path. + +The semantic `Value::Json`, `Value::Entry`, `Value::Enum`, and `Value::Union` +variants have also been removed. JSON recursively uses `Unit`, primitive, +`List`, and `Record` values and rejects `Bytes`; enums use `String`; unions use +a `Record` containing `$case` and, for payload-bearing cases, `value`. Unit +operation results are represented explicitly as `Value::Unit`. + Rust doc comments become descriptor metadata. For an operation, a `# Returns` section documents the return declaration; text before that section documents the operation itself. @@ -155,8 +170,9 @@ let protocol_object = published.protocol_object(); `expose` compiles only when the object implements the generated definition's interface trait. Implementing a trait does not expose it automatically, empty interface sets are valid, insertion order is preserved, and duplicate -references are rejected. Registry references are opaque; descriptor names and -Rust type names are not used as independent wire identity. +references are rejected. Registry references are opaque and remain outside the +descriptor; generated descriptors contain neither those references nor Rust +trait identity. Dispatch is qualified by interface reference and operation name. The host checks current object identity and validator policy, current interface diff --git a/crates/wip-host/src/lib.rs b/crates/wip-host/src/lib.rs index a6acb19..1ef813d 100644 --- a/crates/wip-host/src/lib.rs +++ b/crates/wip-host/src/lib.rs @@ -31,7 +31,7 @@ #![deny(missing_docs)] use std::any::TypeId; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::error::Error; use std::fmt::{self, Display, Formatter}; use std::marker::PhantomData; @@ -42,8 +42,7 @@ use wip_interface::DispatchError; pub use wip_interface::{InterfaceDefinition, InterfaceImplementation}; pub use wip_protocol; use wip_protocol::{ - InterfaceDescriptor, NamedValue, Object, ObjectId, OperationCall, OperationTarget, - ProtocolError, ProtocolErrorCategory, ValidationError, Value, + InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode, ValidationError, Value, }; /// Opaque bytes used as a conditional execution precondition. @@ -64,6 +63,14 @@ impl Validator { } } +/// Host-local identity used by the legacy request-scoped publication API. +/// +/// Canonical protocol calls target a Worldspace path. This wrapper remains +/// temporarily host-local so T-5 can adapt generated values without assigning +/// object identity semantics to the protocol descriptor model. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ObjectId(pub String); + /// Opaque host-assigned reference to one registered interface artifact. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct InterfaceRef(String); @@ -161,8 +168,8 @@ impl InterfaceRegistry { /// `validator` should be present when the host intends the reference to /// represent a versioned descriptor. Its presence makes the same value a /// mandatory precondition for dispatch. Registration finalizes the - /// descriptor's protocol `name` to the assigned opaque reference so it - /// corresponds directly to [`Object::interfaces`]. + /// descriptor remains independent of both registration and its assigned + /// opaque reference. pub fn register( &mut self, _definition: D, @@ -176,7 +183,7 @@ impl InterfaceRegistry { return Err(RegistrationError::AlreadyRegistered); } - let mut descriptor = D::descriptor(); + let descriptor = D::descriptor(); descriptor .validate() .map_err(RegistrationError::InvalidDescriptor)?; @@ -185,11 +192,6 @@ impl InterfaceRegistry { "wip-interface:{}:{}", self.namespace, self.next_reference )); - // The protocol model currently carries the interface identifier in the - // descriptor's `name` field. Finalize that identity to the same opaque - // reference placed on published objects; the generated Rust trait name - // is registration-local metadata, not wire identity. - descriptor.name = reference.0.clone(); self.next_reference = self .next_reference .checked_add(1) @@ -289,7 +291,7 @@ pub trait WipObject { } } -type DispatchFn = fn(&T, &str, Vec) -> Result, DispatchError>; +type DispatchFn = fn(&T, &str, BTreeMap) -> Result; struct BoundInterface { artifact: Arc, @@ -350,13 +352,20 @@ impl PublishedObject { /// Builds the protocol object metadata with interface insertion order intact. #[must_use] pub fn protocol_object(&self) -> Object { + let id = self.implementation.id(); let object = Object { - id: self.implementation.id(), + name: id.0.clone(), + description: None, interfaces: self .interfaces .iter() .map(|bound| bound.artifact.reference.0.clone()) .collect(), + r#ref: Some(id.0), + validator: self + .implementation + .validator() + .map(|validator| validator.0.clone()), }; debug_assert!(object.validate().is_ok()); object @@ -438,14 +447,7 @@ impl PublishedObject { bound .artifact .descriptor - .validate_call(&OperationCall { - target: OperationTarget { - object: request.object.clone(), - interface: bound.artifact.descriptor.name.clone(), - operation: request.operation.clone(), - }, - arguments: request.arguments.clone(), - }) + .validate_arguments(&request.operation, &request.arguments) .map_err(|error| CallError::new(CallErrorKind::InvalidArguments, error.to_string()))?; let value = (bound.dispatch)(&self.implementation, &request.operation, request.arguments) @@ -454,7 +456,7 @@ impl PublishedObject { bound .artifact .descriptor - .validate_return(&request.operation, value.as_ref()) + .validate_result(&request.operation, &value) .map_err(|error| CallError::new(CallErrorKind::InvalidResult, error.to_string()))?; Ok(CallResult { @@ -512,14 +514,14 @@ pub struct CallRequest { /// Operation name within the selected interface descriptor. pub operation: String, /// Named protocol arguments. - pub arguments: Vec, + pub arguments: BTreeMap, } /// Successful host dispatch result. #[derive(Debug, Clone, PartialEq)] pub struct CallResult { - /// Encoded application value, or `None` for a no-return operation. - pub value: Option, + /// Encoded application result. No-data operations return [`Value::Unit`]. + pub value: Value, /// Object validator observed after the implementation returned. pub validator: Option, } @@ -610,20 +612,20 @@ impl CallError { #[must_use] pub fn protocol_error(&self) -> ProtocolError { let category = match self.kind { - CallErrorKind::ObjectNotFound => ProtocolErrorCategory::ObjectNotFound, - CallErrorKind::InterfaceMismatch => ProtocolErrorCategory::InterfaceNotFound, - CallErrorKind::OperationNotFound => ProtocolErrorCategory::OperationNotFound, - CallErrorKind::InvalidArguments => ProtocolErrorCategory::InvalidArguments, + CallErrorKind::ObjectNotFound => ProtocolErrorCode::NotFound, + CallErrorKind::InterfaceMismatch => ProtocolErrorCode::InterfaceMismatch, + CallErrorKind::OperationNotFound => ProtocolErrorCode::OperationNotFound, + CallErrorKind::InvalidArguments => ProtocolErrorCode::InvalidArguments, CallErrorKind::ValidatorRequired | CallErrorKind::ValidatorMismatch | CallErrorKind::InterfaceValidatorRequired - | CallErrorKind::InterfaceValidatorMismatch => ProtocolErrorCategory::InvalidRequest, + | CallErrorKind::InterfaceValidatorMismatch => ProtocolErrorCode::InvalidRequest, CallErrorKind::InvalidResult | CallErrorKind::Implementation => { - ProtocolErrorCategory::Internal + ProtocolErrorCode::Internal } }; ProtocolError { - category, + code: category, message: self.message.clone(), } } diff --git a/crates/wip-host/tests/host.rs b/crates/wip-host/tests/host.rs index 8c5c386..6ae3ab8 100644 --- a/crates/wip-host/tests/host.rs +++ b/crates/wip-host/tests/host.rs @@ -1,14 +1,14 @@ use std::cell::{Cell, RefCell}; +use std::collections::BTreeMap; use wip_host::{ - CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, PublishedObject, Validator, - WipObject, + CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, ObjectId, PublishedObject, + Validator, WipObject, }; use wip_interface::{ - CodecError, HostError, NamedValue, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, - interface, + CodecError, HostError, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, interface, }; -use wip_protocol::{ObjectId, ProtocolErrorCategory}; +use wip_protocol::ProtocolErrorCode; #[interface] pub trait Reader { @@ -143,7 +143,7 @@ fn request( reference: &wip_host::InterfaceRef, interface_validator: Option<&[u8]>, operation: &str, - arguments: Vec, + arguments: BTreeMap, ) -> CallRequest { CallRequest { object: ObjectId(object.into()), @@ -170,7 +170,7 @@ fn registry_exposes_opaque_validated_descriptor_observations() { 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()); + assert_eq!(observation.descriptor, ReaderDefinition::descriptor()); assert_eq!(observation.validator, None); observation.descriptor.validate().unwrap(); @@ -207,15 +207,18 @@ fn publication_is_explicit_ordered_qualified_and_request_scoped() { reader.reference().as_str().to_owned() ] ); - wip_protocol::InterfaceObservation { - object: object.clone(), - descriptors: vec![ - registry.observe(writer.reference()).unwrap().descriptor, - registry.observe(reader.reference()).unwrap().descriptor, - ], - } - .validate() - .unwrap(); + registry + .observe(writer.reference()) + .unwrap() + .descriptor + .validate() + .unwrap(); + registry + .observe(reader.reference()) + .unwrap() + .descriptor + .validate() + .unwrap(); assert!(!format!("{object:?}").contains("private/backend/path")); let writer_result = publication @@ -225,7 +228,7 @@ fn publication_is_explicit_ordered_qualified_and_request_scoped() { writer.reference(), None, "label", - vec![], + BTreeMap::new(), )) .unwrap(); let reader_result = publication @@ -235,11 +238,11 @@ fn publication_is_explicit_ordered_qualified_and_request_scoped() { reader.reference(), None, "label", - vec![], + BTreeMap::new(), )) .unwrap(); - assert_eq!(writer_result.value, Some(Value::String("writer".into()))); - assert_eq!(reader_result.value, Some(Value::String("reader".into()))); + assert_eq!(writer_result.value, Value::String("writer".into())); + assert_eq!(reader_result.value, Value::String("reader".into())); assert_eq!(publication.implementation().calls.get(), 2); assert_eq!( publication @@ -267,13 +270,13 @@ fn implemented_but_unexposed_interface_is_not_a_member() { hidden.reference(), None, "hidden", - vec![], + BTreeMap::new(), )) .unwrap_err(); assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch); assert_eq!( - error.protocol_error().category, - ProtocolErrorCategory::InterfaceNotFound + error.protocol_error().code, + ProtocolErrorCode::InterfaceMismatch ); assert_eq!(publication.implementation().calls.get(), 0); } @@ -282,7 +285,8 @@ fn implemented_but_unexposed_interface_is_not_a_member() { fn zero_interface_objects_are_valid() { let publication = PublishedObject::new(Document::new("empty")); let object = publication.protocol_object(); - assert_eq!(object.id, ObjectId("empty".into())); + assert_eq!(object.name, "empty"); + assert_eq!(object.r#ref.as_deref(), Some("empty")); assert!(object.interfaces.is_empty()); object.validate().unwrap(); } @@ -316,13 +320,13 @@ fn operation_and_argument_failures_are_mapped_without_execution() { reader.reference(), None, "missing", - vec![], + BTreeMap::new(), )) .unwrap_err(); assert_eq!(missing.kind(), CallErrorKind::OperationNotFound); assert_eq!( - missing.protocol_error().category, - ProtocolErrorCategory::OperationNotFound + missing.protocol_error().code, + ProtocolErrorCode::OperationNotFound ); let invalid = publication @@ -332,16 +336,13 @@ fn operation_and_argument_failures_are_mapped_without_execution() { reader.reference(), None, "add", - vec![NamedValue { - name: "left".into(), - value: Value::String("wrong".into()), - }], + BTreeMap::from([("left".into(), Value::String("wrong".into()))]), )) .unwrap_err(); assert_eq!(invalid.kind(), CallErrorKind::InvalidArguments); assert_eq!( - invalid.protocol_error().category, - ProtocolErrorCategory::InvalidArguments + invalid.protocol_error().code, + ProtocolErrorCode::InvalidArguments ); assert_eq!(publication.implementation().calls.get(), 0); } @@ -361,14 +362,11 @@ fn invalid_implementation_result_is_internal_and_not_returned() { broken.reference(), None, "broken", - vec![], + BTreeMap::new(), )) .unwrap_err(); assert_eq!(error.kind(), CallErrorKind::InvalidResult); - assert_eq!( - error.protocol_error().category, - ProtocolErrorCategory::Internal - ); + assert_eq!(error.protocol_error().code, ProtocolErrorCode::Internal); assert_eq!(publication.implementation().calls.get(), 1); } @@ -387,7 +385,7 @@ fn object_validator_missing_and_mismatch_are_distinct_and_prevent_execution() { reader.reference(), None, "label", - vec![], + BTreeMap::new(), )) .unwrap_err(); assert_eq!(missing.kind(), CallErrorKind::ValidatorRequired); @@ -399,7 +397,7 @@ fn object_validator_missing_and_mismatch_are_distinct_and_prevent_execution() { reader.reference(), None, "label", - vec![], + BTreeMap::new(), )) .unwrap_err(); assert_eq!(mismatch.kind(), CallErrorKind::ValidatorMismatch); @@ -412,7 +410,7 @@ fn object_validator_missing_and_mismatch_are_distinct_and_prevent_execution() { reader.reference(), None, "label", - vec![], + BTreeMap::new(), )) .unwrap(); assert_eq!(result.validator, Some(Validator::new(b"object-v1"))); @@ -439,7 +437,7 @@ fn interface_validator_missing_and_mismatch_are_distinct_and_prevent_execution() writer.reference(), None, "label", - vec![], + BTreeMap::new(), )) .unwrap_err(); assert_eq!(missing.kind(), CallErrorKind::InterfaceValidatorRequired); @@ -451,7 +449,7 @@ fn interface_validator_missing_and_mismatch_are_distinct_and_prevent_execution() writer.reference(), Some(b"schema-v0"), "label", - vec![], + BTreeMap::new(), )) .unwrap_err(); assert_eq!(mismatch.kind(), CallErrorKind::InterfaceValidatorMismatch); @@ -464,10 +462,10 @@ fn interface_validator_missing_and_mismatch_are_distinct_and_prevent_execution() writer.reference(), Some(b"schema-v1"), "label", - vec![], + BTreeMap::new(), )) .unwrap(); - assert_eq!(result.value, Some(Value::String("writer".into()))); + assert_eq!(result.value, Value::String("writer".into())); } #[test] @@ -496,12 +494,9 @@ fn host_failures_map_to_internal_without_becoming_domain_values() { failing.reference(), None, "fail", - vec![], + BTreeMap::new(), )) .unwrap_err(); assert_eq!(error.kind(), CallErrorKind::Implementation); - assert_eq!( - error.protocol_error().category, - ProtocolErrorCategory::Internal - ); + assert_eq!(error.protocol_error().code, ProtocolErrorCode::Internal); } diff --git a/crates/wip-host/tests/ui/unimplemented_interface.rs b/crates/wip-host/tests/ui/unimplemented_interface.rs index 4622450..368cb50 100644 --- a/crates/wip-host/tests/ui/unimplemented_interface.rs +++ b/crates/wip-host/tests/ui/unimplemented_interface.rs @@ -1,6 +1,5 @@ -use wip_host::{InterfaceRegistry, PublishedObject, WipObject}; +use wip_host::{InterfaceRegistry, ObjectId, PublishedObject, WipObject}; use wip_interface::{OperationResult, interface}; -use wip_protocol::ObjectId; #[interface] pub trait Search { diff --git a/crates/wip-host/tests/ui/unimplemented_interface.stderr b/crates/wip-host/tests/ui/unimplemented_interface.stderr index 8b817ad..5e49267 100644 --- a/crates/wip-host/tests/ui/unimplemented_interface.stderr +++ b/crates/wip-host/tests/ui/unimplemented_interface.stderr @@ -1,27 +1,27 @@ error[E0277]: the trait bound `Object: Search` is not satisfied - --> tests/ui/unimplemented_interface.rs:21:58 + --> tests/ui/unimplemented_interface.rs:20:58 | -21 | let _published = PublishedObject::new(Object).expose(&search).unwrap(); +20 | let _published = PublishedObject::new(Object).expose(&search).unwrap(); | ------ ^^^^^^^ unsatisfied trait bound | | | required by a bound introduced by this call | help: the trait `Search` is not implemented for `Object` - --> tests/ui/unimplemented_interface.rs:10:1 + --> tests/ui/unimplemented_interface.rs:9:1 | -10 | struct Object; + 9 | struct Object; | ^^^^^^^^^^^^^ help: this trait has no implementations, consider adding one - --> tests/ui/unimplemented_interface.rs:6:1 - | - 6 | pub trait Search { - | ^^^^^^^^^^^^^^^^ -note: required for `SearchDefinition` to implement `InterfaceImplementation` --> tests/ui/unimplemented_interface.rs:5:1 | - 5 | #[interface] + 5 | pub trait Search { + | ^^^^^^^^^^^^^^^^ +note: required for `SearchDefinition` to implement `InterfaceImplementation` + --> tests/ui/unimplemented_interface.rs:4:1 + | + 4 | #[interface] | ^^^^^^^^^^^^ - 6 | pub trait Search { + 5 | pub trait Search { | ^^^^^^ note: required by a bound in `PublishedObject::::expose` --> src/lib.rs diff --git a/crates/wip-interface-macros/src/lib.rs b/crates/wip-interface-macros/src/lib.rs index 976f21b..3bc1ea7 100644 --- a/crates/wip-interface-macros/src/lib.rs +++ b/crates/wip-interface-macros/src/lib.rs @@ -168,9 +168,11 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result syn::Result, + arguments: ::std::collections::BTreeMap< + ::std::string::String, + ::wip_interface::wip_protocol::Value, + >, ) -> ::std::result::Result< - ::std::option::Option<::wip_interface::wip_protocol::Value>, + ::wip_interface::wip_protocol::Value, ::wip_interface::DispatchError, > { let descriptor = Self::descriptor(); @@ -207,9 +212,12 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result( implementation: &T, operation: &str, - arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, + arguments: ::std::collections::BTreeMap< + ::std::string::String, + ::wip_interface::wip_protocol::Value, + >, ) -> ::std::result::Result< - ::std::option::Option<::wip_interface::wip_protocol::Value>, + ::wip_interface::wip_protocol::Value, ::wip_interface::DispatchError, > { >::dispatch( @@ -523,12 +531,11 @@ fn argument_item(operation: &Operation) -> TokenStream2 { let encode_result = if operation.unit_result { quote! { let () = result; - ::std::result::Result::Ok(::std::option::Option::None) + ::std::result::Result::Ok(::wip_interface::wip_protocol::Value::Unit) } } else { quote! { <#result_ty as ::wip_interface::WipType>::encode(result) - .map(::std::option::Option::Some) } }; @@ -541,9 +548,12 @@ fn argument_item(operation: &Operation) -> TokenStream2 { impl #arguments_ident { /// Decodes named logical values into typed operation arguments. pub fn decode( - arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, + arguments: ::std::collections::BTreeMap< + ::std::string::String, + ::wip_interface::wip_protocol::Value, + >, ) -> ::std::result::Result { - let mut decoder = ::wip_interface::__private::RecordDecoder::new(arguments)?; + let mut decoder = ::wip_interface::__private::RecordDecoder::new(arguments); let decoded = Self { #(#decoders,)* }; @@ -555,7 +565,7 @@ fn argument_item(operation: &Operation) -> TokenStream2 { pub fn encode_result( result: #result_ty, ) -> ::std::result::Result< - ::std::option::Option<::wip_interface::wip_protocol::Value>, + ::wip_interface::wip_protocol::Value, ::wip_interface::CodecError, > { #encode_result @@ -575,22 +585,18 @@ fn operation_declaration(operation: &Operation) -> TokenStream2 { quote! { ::wip_interface::wip_protocol::ParameterDeclaration { name: ::std::string::String::from(#parameter_name), + required: !#optional, documentation: #parameter_doc, - ty: <#ty as ::wip_interface::WipType>::type_expr(), - optional: #optional, + r#type: <#ty as ::wip_interface::WipType>::type_expr(), } } }); - let returns = if operation.unit_result { - quote!(::std::option::Option::None) - } else { - let result_ty = &operation.result_ty; - let return_doc = documentation_tokens(operation.return_documentation.as_ref()); - quote! { - ::std::option::Option::Some(::wip_interface::wip_protocol::ReturnDeclaration { - documentation: #return_doc, - ty: <#result_ty as ::wip_interface::WipType>::type_expr(), - }) + let result_ty = &operation.result_ty; + let return_doc = documentation_tokens(operation.return_documentation.as_ref()); + let returns = quote! { + ::wip_interface::wip_protocol::ReturnDeclaration { + documentation: #return_doc, + r#type: <#result_ty as ::wip_interface::WipType>::type_expr(), } }; @@ -646,7 +652,7 @@ fn dispatch_arm(operation: &Operation) -> TokenStream2 { let encoded = #arguments_ident::encode_result(result) .map_err(::wip_interface::DispatchError::Encode)?; descriptor - .validate_return(#operation_name, encoded.as_ref()) + .validate_result(#operation_name, &encoded) .map_err(::wip_interface::DispatchError::InvalidResult)?; ::std::result::Result::Ok(encoded) } @@ -747,9 +753,9 @@ fn derive_record( quote! { ::wip_interface::wip_protocol::FieldDeclaration { name: ::std::string::String::from(#name), + required: !#optional, documentation: #documentation, - ty: <#ty as ::wip_interface::WipType>::type_expr(), - optional: #optional, + r#type: <#ty as ::wip_interface::WipType>::type_expr(), } } }); @@ -765,18 +771,18 @@ fn derive_record( if field.optional { quote! { if let ::std::option::Option::Some(value) = #field_ident { - fields.push(::wip_interface::wip_protocol::NamedValue { - name: ::std::string::String::from(#field_name), - value: <#ty as ::wip_interface::WipType>::encode(value)?, - }); + fields.insert( + ::std::string::String::from(#field_name), + <#ty as ::wip_interface::WipType>::encode(value)?, + ); } } } else { quote! { - fields.push(::wip_interface::wip_protocol::NamedValue { - name: ::std::string::String::from(#field_name), - value: <#ty as ::wip_interface::WipType>::encode(#field_ident)?, - }); + fields.insert( + ::std::string::String::from(#field_name), + <#ty as ::wip_interface::WipType>::encode(#field_ident)?, + ); } } }); @@ -794,9 +800,9 @@ fn derive_record( Ok(quote! { impl ::wip_interface::WipType for #ident { fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { - ::wip_interface::wip_protocol::TypeExpr::Named( - ::std::string::String::from(#type_name), - ) + ::wip_interface::wip_protocol::TypeExpr::Named { + name: ::std::string::String::from(#type_name), + } } fn collect_declarations( @@ -804,13 +810,13 @@ fn derive_record( ::wip_interface::wip_protocol::TypeDeclaration, >, ) { - let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Record( - ::wip_interface::wip_protocol::RecordDeclaration { - name: ::std::string::String::from(#type_name), - documentation: #declaration_doc, + let declaration = ::wip_interface::wip_protocol::TypeDeclaration { + name: ::std::string::String::from(#type_name), + documentation: #declaration_doc, + definition: ::wip_interface::wip_protocol::TypeExpr::Record { fields: ::std::vec![#(#field_declarations),*], }, - ); + }; if ::wip_interface::__private::register_declaration(declarations, declaration) { #(#dependency_collectors)* } @@ -823,7 +829,7 @@ fn derive_record( ::wip_interface::CodecError, > { let Self { #(#destructured_fields),* } = self; - let mut fields = ::std::vec::Vec::new(); + let mut fields = ::std::collections::BTreeMap::new(); #(#encoders)* ::std::result::Result::Ok( ::wip_interface::wip_protocol::Value::Record(fields), @@ -835,7 +841,7 @@ fn derive_record( ) -> ::std::result::Result { match value { ::wip_interface::wip_protocol::Value::Record(fields) => { - let mut decoder = ::wip_interface::__private::RecordDecoder::new(fields)?; + let mut decoder = ::wip_interface::__private::RecordDecoder::new(fields); let decoded = Self { #(#decoders,)* }; @@ -870,14 +876,14 @@ fn derive_enum( .variants .iter() .all(|variant| matches!(variant.fields, Fields::Unit)); - let all_payload = !data.variants.is_empty() - && data.variants.iter().all(|variant| { - matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1) - }); + let valid_union = data.variants.iter().all(|variant| { + matches!(variant.fields, Fields::Unit) + || matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1) + }); if all_unit { derive_unit_enum(ident, type_name, type_documentation, data) - } else if all_payload { + } else if valid_union { derive_union_enum(ident, type_name, type_documentation, data) } else { let span = data @@ -890,7 +896,7 @@ fn derive_enum( .map_or_else(|| data.enum_token.span, Spanned::span); Err(Error::new( span, - "a `WipType` enum must contain either only unit variants or only single-field tuple variants; mixed and named-field variants are unsupported", + "a `WipType` enum must contain unit variants and/or single-field tuple variants; named-field and multi-field variants are unsupported", )) } } @@ -906,7 +912,7 @@ fn derive_unit_enum( let name = source_name(&variant.ident); let documentation = documentation_tokens(documentation(&variant.attrs).as_ref()); quote! { - ::wip_interface::wip_protocol::EnumVariantDeclaration { + ::wip_interface::wip_protocol::EnumCase { name: ::std::string::String::from(#name), documentation: #documentation, } @@ -926,9 +932,9 @@ fn derive_unit_enum( Ok(quote! { impl ::wip_interface::WipType for #ident { fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { - ::wip_interface::wip_protocol::TypeExpr::Named( - ::std::string::String::from(#type_name), - ) + ::wip_interface::wip_protocol::TypeExpr::Named { + name: ::std::string::String::from(#type_name), + } } fn collect_declarations( @@ -936,13 +942,13 @@ fn derive_unit_enum( ::wip_interface::wip_protocol::TypeDeclaration, >, ) { - let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Enum( - ::wip_interface::wip_protocol::EnumDeclaration { - name: ::std::string::String::from(#type_name), - documentation: #declaration_doc, - variants: ::std::vec![#(#declarations),*], + let declaration = ::wip_interface::wip_protocol::TypeDeclaration { + name: ::std::string::String::from(#type_name), + documentation: #declaration_doc, + definition: ::wip_interface::wip_protocol::TypeExpr::Enum { + cases: ::std::vec![#(#declarations),*], }, - ); + }; let _ = ::wip_interface::__private::register_declaration( declarations, declaration, @@ -959,7 +965,7 @@ fn derive_unit_enum( #(#encoders,)* }; ::std::result::Result::Ok( - ::wip_interface::wip_protocol::Value::Enum( + ::wip_interface::wip_protocol::Value::String( ::std::string::String::from(variant), ), ) @@ -969,7 +975,7 @@ fn derive_unit_enum( value: ::wip_interface::wip_protocol::Value, ) -> ::std::result::Result { match value { - ::wip_interface::wip_protocol::Value::Enum(variant) => { + ::wip_interface::wip_protocol::Value::String(variant) => { match variant.as_str() { #(#decoders,)* _ => ::std::result::Result::Err( @@ -998,35 +1004,36 @@ fn derive_union_enum( struct Variant<'a> { ident: &'a Ident, name: String, - ty: &'a Type, + payload: Option<&'a Type>, documentation: Option, } let mut variants = Vec::new(); for variant in &data.variants { - let Fields::Unnamed(fields) = &variant.fields else { - return Err(Error::new_spanned( - variant, - "a `WipType` union variant must contain exactly one unnamed field", - )); + let payload = match &variant.fields { + Fields::Unit => None, + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + let field = fields.unnamed.first().expect("length checked"); + if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) { + return Err(Error::new_spanned( + &field.ty, + "`Option` is only supported directly on DTO fields and operation parameters", + )); + } + validate_value_type(&field.ty, "union case payload")?; + Some(&field.ty) + } + _ => { + return Err(Error::new_spanned( + variant, + "a `WipType` union case must be unit-like or contain exactly one unnamed payload", + )); + } }; - let Some(field) = fields.unnamed.first() else { - return Err(Error::new_spanned( - fields, - "a `WipType` union variant must contain exactly one unnamed field", - )); - }; - if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) { - return Err(Error::new_spanned( - &field.ty, - "`Option` is only supported directly on DTO fields and operation parameters", - )); - } - validate_value_type(&field.ty, "union variant payload")?; variants.push(Variant { ident: &variant.ident, name: source_name(&variant.ident), - ty: &field.ty, + payload, documentation: documentation(&variant.attrs), }); } @@ -1035,50 +1042,95 @@ fn derive_union_enum( let declarations = variants.iter().map(|variant| { let name = &variant.name; let documentation = documentation_tokens(variant.documentation.as_ref()); - let ty = variant.ty; + let payload = match variant.payload { + Some(ty) => quote! { + ::std::option::Option::Some( + <#ty as ::wip_interface::WipType>::type_expr(), + ) + }, + None => quote!(::std::option::Option::None), + }; quote! { - ::wip_interface::wip_protocol::UnionVariantDeclaration { + ::wip_interface::wip_protocol::UnionCase { name: ::std::string::String::from(#name), documentation: #documentation, - ty: <#ty as ::wip_interface::WipType>::type_expr(), + payload: #payload, } } }); - let dependency_collectors = variants.iter().map(|variant| { - let ty = variant.ty; - quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);) + let dependency_collectors = variants.iter().filter_map(|variant| { + variant.payload.map( + |ty| quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);), + ) }); let encoders = variants.iter().map(|variant| { let variant_ident = variant.ident; let name = &variant.name; - let ty = variant.ty; - quote! { - Self::#variant_ident(value) => { - ::wip_interface::wip_protocol::Value::Union { - variant: ::std::string::String::from(#name), - value: ::std::boxed::Box::new( + match variant.payload { + Some(ty) => quote! { + Self::#variant_ident(value) => { + let mut fields = ::std::collections::BTreeMap::new(); + fields.insert( + ::std::string::String::from( + ::wip_interface::wip_protocol::UNION_CASE_FIELD, + ), + ::wip_interface::wip_protocol::Value::String( + ::std::string::String::from(#name), + ), + ); + fields.insert( + ::std::string::String::from( + ::wip_interface::wip_protocol::UNION_VALUE_FIELD, + ), <#ty as ::wip_interface::WipType>::encode(value)?, - ), + ); + ::wip_interface::wip_protocol::Value::Record(fields) } - } + }, + None => quote! { + Self::#variant_ident => { + let mut fields = ::std::collections::BTreeMap::new(); + fields.insert( + ::std::string::String::from( + ::wip_interface::wip_protocol::UNION_CASE_FIELD, + ), + ::wip_interface::wip_protocol::Value::String( + ::std::string::String::from(#name), + ), + ); + ::wip_interface::wip_protocol::Value::Record(fields) + } + }, } }); let decoders = variants.iter().map(|variant| { let variant_ident = variant.ident; let name = &variant.name; - let ty = variant.ty; - quote! { - #name => <#ty as ::wip_interface::WipType>::decode(*value) - .map(Self::#variant_ident) + match variant.payload { + Some(ty) => quote! { + #name => { + let value = decoder.required::<#ty>( + ::wip_interface::wip_protocol::UNION_VALUE_FIELD, + )?; + decoder.finish()?; + ::std::result::Result::Ok(Self::#variant_ident(value)) + } + }, + None => quote! { + #name => { + decoder.finish()?; + ::std::result::Result::Ok(Self::#variant_ident) + } + }, } }); Ok(quote! { impl ::wip_interface::WipType for #ident { fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { - ::wip_interface::wip_protocol::TypeExpr::Named( - ::std::string::String::from(#type_name), - ) + ::wip_interface::wip_protocol::TypeExpr::Named { + name: ::std::string::String::from(#type_name), + } } fn collect_declarations( @@ -1086,13 +1138,13 @@ fn derive_union_enum( ::wip_interface::wip_protocol::TypeDeclaration, >, ) { - let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Union( - ::wip_interface::wip_protocol::UnionDeclaration { - name: ::std::string::String::from(#type_name), - documentation: #declaration_doc, - variants: ::std::vec![#(#declarations),*], + let declaration = ::wip_interface::wip_protocol::TypeDeclaration { + name: ::std::string::String::from(#type_name), + documentation: #declaration_doc, + definition: ::wip_interface::wip_protocol::TypeExpr::Union { + cases: ::std::vec![#(#declarations),*], }, - ); + }; if ::wip_interface::__private::register_declaration(declarations, declaration) { #(#dependency_collectors)* } @@ -1114,26 +1166,30 @@ fn derive_union_enum( value: ::wip_interface::wip_protocol::Value, ) -> ::std::result::Result { match value { - ::wip_interface::wip_protocol::Value::Union { variant, value } => { - match variant.as_str() { + ::wip_interface::wip_protocol::Value::Record(fields) => { + let mut decoder = + ::wip_interface::__private::RecordDecoder::new(fields); + let case = decoder.required::<::std::string::String>( + ::wip_interface::wip_protocol::UNION_CASE_FIELD, + )?; + match case.as_str() { #(#decoders,)* _ => ::std::result::Result::Err( ::wip_interface::__private::decode_unknown_variant( #type_name, - variant, + case, ), ), } } actual => ::std::result::Result::Err( - ::wip_interface::__private::decode_type_mismatch("union", actual), + ::wip_interface::__private::decode_type_mismatch("union record", actual), ), } } } }) } - fn optional_type(ty: &Type, context: &str) -> syn::Result<(bool, Type)> { if let Some(inner) = direct_option(ty)? { if contains_option(&inner) { diff --git a/crates/wip-interface/src/lib.rs b/crates/wip-interface/src/lib.rs index 14de629..8e6fe7b 100644 --- a/crates/wip-interface/src/lib.rs +++ b/crates/wip-interface/src/lib.rs @@ -30,18 +30,18 @@ //! //! | Rust type | Protocol type/value | //! | --- | --- | -//! | `()` as an operation result | no return declaration / no value | +//! | `()` | `Unit` | //! | `bool` | `Boolean` | //! | signed and unsigned integer primitives | JavaScript-lossless `Integer` | //! | `f32`, `f64` | finite `Number` | //! | `String` | `String` | //! | [`Bytes`] | `Bytes` | -//! | [`wip_protocol::JsonValue`] | `Json` | -//! | [`Entry`] | `Entry` | +//! | [`JsonValue`] | schema-neutral `Json` value tree | +//! | [`Entry`] | canonical absolute Worldspace path (`Entry`) | //! | `Vec` | `List` | //! | a named-field struct deriving [`WipType`] | named record | //! | an all-unit enum deriving [`WipType`] | named enum | -//! | an all-newtype enum deriving [`WipType`] | named tagged union | +//! | an enum mixing unit and single-payload cases deriving [`WipType`] | named union | //! //! `Option` is supported only directly as a record field or operation //! parameter. It means omission, not a null value. A domain failure belongs in @@ -49,33 +49,44 @@ //! [`HostError`] is reserved for host/runtime failure, so the required //! [`OperationResult`] convention keeps those two failure classes distinct. //! +//! # Migration to canonical values +//! +//! Earlier releases exposed `Entry` and semantic `Value::Json`, +//! `Value::Entry`, `Value::Enum`, and `Value::Union` variants. Those forms are +//! intentionally gone: [`Entry`] is now one validated absolute Worldspace path, +//! while JSON, enum, and union meaning comes only from the descriptor. Codecs +//! exchange only raw [`Value`] strings, records, lists, and primitives. Unit +//! operation results now cross the boundary as [`Value::Unit`] rather than an +//! absent value. +//! //! # Supported interface syntax //! //! An interface is a public, non-generic trait whose operations use exactly //! `&self`, simple named owned parameters, and an explicit //! `OperationResult`. `WipType` derives support named-field structs, -//! all-unit enums, and all-single-payload enums. +//! all-unit enums as protocol enums, and enums made from any mix of unit and +//! single-payload cases as protocol unions. //! //! The macros reject trait inheritance and associated items; const, async, //! unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods; //! borrowed values and custom lifetimes; tuple, unit, or generic structs; -//! mixed or named-field enum variants; explicit discriminants; Rust unions; +//! unsupported named-field or multi-field enum variants; explicit discriminants; Rust unions; //! and `Option` anywhere except a direct DTO field or operation parameter. //! Unsupported inputs produce source-located compile errors rather than //! generating a partial interface. #![deny(missing_docs)] +use std::collections::BTreeMap; use std::error::Error; use std::fmt::{self, Display, Formatter}; pub use wip_interface_macros::{WipType, interface}; pub use wip_protocol; pub use wip_protocol::{ - Documentation, EnumDeclaration, EnumVariantDeclaration, FieldDeclaration, InterfaceDescriptor, - JsonMember, JsonValue, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NamedValue, OperationDeclaration, - ParameterDeclaration, RecordDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr, - UnionDeclaration, UnionVariantDeclaration, ValidationError, Value, + Documentation, EnumCase, FieldDeclaration, InterfaceDescriptor, MAX_SAFE_INTEGER, + MIN_SAFE_INTEGER, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, + TypeDeclaration, TypeExpr, UnionCase, ValidationError, Value, }; /// Converts one Rust application type to and from its protocol representation. @@ -118,8 +129,8 @@ pub trait InterfaceImplementation: InterfaceDefinition { fn dispatch( implementation: &T, operation: &str, - arguments: Vec, - ) -> Result, DispatchError>; + arguments: BTreeMap, + ) -> Result; } /// Result convention required for generated interface operations. @@ -281,13 +292,101 @@ impl From for Vec { } } -/// A typed protocol key/value entry. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Entry { - /// Entry key. - pub key: K, - /// Entry value. - pub value: V, +/// A canonical absolute path to an entry in the current Worldspace. +/// +/// This replaces the pre-canonical `Entry` key/value pair. Construct an +/// entry from a path such as `/articles/1`; application data associated with +/// that entry belongs in an ordinary record alongside this value. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Entry(String); + +impl Entry { + /// Validates and creates an entry path. + pub fn new(path: impl Into) -> Result { + let path = path.into(); + validate_entry_path(&path)?; + Ok(Self(path)) + } + + /// Returns the canonical absolute Worldspace path. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consumes the entry and returns its path. + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl TryFrom for Entry { + type Error = CodecError; + + fn try_from(path: String) -> Result { + Self::new(path) + } +} + +impl std::str::FromStr for Entry { + type Err = CodecError; + + fn from_str(path: &str) -> Result { + Self::new(path) + } +} + +/// One member in a [`JsonValue::Object`]. +#[derive(Debug, Clone, PartialEq)] +pub struct JsonMember { + /// Member name. + pub name: String, + /// Member value. + pub value: JsonValue, +} + +/// JSON data represented independently of any application schema. +/// +/// Its codec uses the ordinary schema-neutral [`Value`] variants. JSON null is +/// [`Value::Unit`], arrays are [`Value::List`], and objects are +/// [`Value::Record`]. Protocol bytes have no JSON representation and decoding +/// a tree containing [`Value::Bytes`] is rejected. +#[derive(Debug, Clone, PartialEq)] +pub enum JsonValue { + /// JSON null. + Null, + /// A JSON boolean. + Boolean(bool), + /// An interoperable integral JSON number. + Integer(i64), + /// A finite non-integral JSON number. + Number(f64), + /// A JSON string. + String(String), + /// A JSON array. + Array(Vec), + /// A JSON object. Duplicate member names are rejected while encoding. + Object(Vec), +} + +impl WipType for () { + fn type_expr() -> TypeExpr { + TypeExpr::Unit + } + + fn collect_declarations(_: &mut Vec) {} + + fn encode(self) -> Result { + Ok(Value::Unit) + } + + fn decode(value: Value) -> Result { + match value { + Value::Unit => Ok(()), + other => Err(CodecError::expected("unit", &other)), + } + } } impl WipType for bool { @@ -456,52 +555,87 @@ impl WipType for JsonValue { fn collect_declarations(_: &mut Vec) {} fn encode(self) -> Result { - validate_json(&self)?; - Ok(Value::Json(self)) + encode_json(self) } fn decode(value: Value) -> Result { - match value { - Value::Json(value) => { - validate_json(&value)?; - Ok(value) + decode_json(value) + } +} + +fn encode_json(value: JsonValue) -> Result { + match value { + JsonValue::Null => Ok(Value::Unit), + JsonValue::Boolean(value) => Ok(Value::Boolean(value)), + JsonValue::Integer(value) => { + ensure_safe_integer(value)?; + Ok(Value::Integer(value)) + } + JsonValue::Number(value) => { + ensure_finite(value)?; + Ok(Value::Number(value)) + } + JsonValue::String(value) => Ok(Value::String(value)), + JsonValue::Array(values) => values + .into_iter() + .enumerate() + .map(|(index, value)| encode_json(value).map_err(|error| error.at(index.to_string()))) + .collect::, _>>() + .map(Value::List), + JsonValue::Object(members) => { + let mut fields = BTreeMap::new(); + for member in members { + let name = member.name; + let value = encode_json(member.value).map_err(|error| error.at(name.clone()))?; + if fields.insert(name.clone(), value).is_some() { + return Err(CodecError::new(format!("duplicate JSON member `{name}`")).at(name)); + } } - other => Err(CodecError::expected("json", &other)), + Ok(Value::Record(fields)) } } } -fn validate_json(value: &JsonValue) -> Result<(), CodecError> { +fn decode_json(value: Value) -> Result { match value { - JsonValue::Integer(value) => ensure_safe_integer(*value), - JsonValue::Number(value) => ensure_finite(*value), - JsonValue::Array(values) => { - for (index, value) in values.iter().enumerate() { - validate_json(value).map_err(|error| error.at(index.to_string()))?; - } - Ok(()) + Value::Unit => Ok(JsonValue::Null), + Value::Boolean(value) => Ok(JsonValue::Boolean(value)), + Value::Integer(value) => { + ensure_safe_integer(value)?; + Ok(JsonValue::Integer(value)) } - JsonValue::Object(members) => { - let mut names = std::collections::HashSet::new(); - for member in members { - if !names.insert(member.name.as_str()) { - return Err(CodecError::new(format!( - "duplicate JSON member `{}`", - member.name - )) - .at(member.name.clone())); - } - validate_json(&member.value).map_err(|error| error.at(member.name.clone()))?; - } - Ok(()) + Value::Number(value) => { + ensure_finite(value)?; + Ok(JsonValue::Number(value)) } - JsonValue::Null | JsonValue::Boolean(_) | JsonValue::String(_) => Ok(()), + Value::String(value) => Ok(JsonValue::String(value)), + Value::Bytes(_) => Err(CodecError::new("bytes are not valid inside JSON")), + Value::List(values) => values + .into_iter() + .enumerate() + .map(|(index, value)| decode_json(value).map_err(|error| error.at(index.to_string()))) + .collect::, _>>() + .map(JsonValue::Array), + Value::Record(fields) => fields + .into_iter() + .map(|(name, value)| { + decode_json(value) + .map(|value| JsonMember { + name: name.clone(), + value, + }) + .map_err(|error| error.at(name)) + }) + .collect::, _>>() + .map(JsonValue::Object), } } impl WipType for Vec { fn type_expr() -> TypeExpr { - TypeExpr::List(Box::new(T::type_expr())) + TypeExpr::List { + items: Box::new(T::type_expr()), + } } fn collect_declarations(declarations: &mut Vec) { @@ -528,37 +662,31 @@ impl WipType for Vec { } } -impl WipType for Entry { +impl WipType for Entry { fn type_expr() -> TypeExpr { - TypeExpr::Entry { - key: Box::new(K::type_expr()), - value: Box::new(V::type_expr()), - } + TypeExpr::Entry } - fn collect_declarations(declarations: &mut Vec) { - K::collect_declarations(declarations); - V::collect_declarations(declarations); - } + fn collect_declarations(_: &mut Vec) {} fn encode(self) -> Result { - Ok(Value::Entry { - key: Box::new(self.key.encode().map_err(|error| error.at("key"))?), - value: Box::new(self.value.encode().map_err(|error| error.at("value"))?), - }) + validate_entry_path(&self.0)?; + Ok(Value::String(self.0)) } fn decode(value: Value) -> Result { - let Value::Entry { key, value } = value else { - return Err(CodecError::expected("entry", &value)); + let Value::String(path) = value else { + return Err(CodecError::expected("entry path string", &value)); }; - Ok(Self { - key: K::decode(*key).map_err(|error| error.at("key"))?, - value: V::decode(*value).map_err(|error| error.at("value"))?, - }) + Self::new(path) } } +fn validate_entry_path(path: &str) -> Result<(), CodecError> { + wip_protocol::validate_path(path) + .map_err(|error| CodecError::new(format!("invalid canonical Worldspace path: {error}"))) +} + /// Implementation details used by code emitted from the facade's macros. /// /// These APIs are public only because macro expansions execute in the @@ -566,12 +694,9 @@ impl WipType for Entry { /// and are not intended for direct use. #[doc(hidden)] pub mod __private { - use std::collections::HashSet; + use std::collections::BTreeMap; - use wip_protocol::{ - InterfaceDescriptor, NamedValue, ObjectId, OperationCall, OperationTarget, TypeDeclaration, - ValidationError, Value, - }; + use wip_protocol::{InterfaceDescriptor, TypeDeclaration, ValidationError, Value}; use crate::{CodecError, Documentation, WipType}; @@ -606,16 +731,9 @@ pub mod __private { pub fn validate_arguments( descriptor: &InterfaceDescriptor, operation: &str, - arguments: &[NamedValue], + arguments: &BTreeMap, ) -> Result<(), ValidationError> { - descriptor.validate_call(&OperationCall { - target: OperationTarget { - object: ObjectId("".into()), - interface: descriptor.name.clone(), - operation: operation.into(), - }, - arguments: arguments.to_vec(), - }) + descriptor.validate_arguments(operation, arguments) } /// Builds a codec error for a generated structural mismatch. @@ -633,21 +751,13 @@ pub mod __private { /// Decoder for a protocol record or operation's named argument collection. pub struct RecordDecoder { - fields: Vec, + fields: BTreeMap, } impl RecordDecoder { - /// Starts decoding record fields or named operation arguments and - /// rejects duplicate names. - pub fn new(fields: Vec) -> Result { - let mut seen = HashSet::new(); - for field in &fields { - if !seen.insert(field.name.clone()) { - return Err(CodecError::new(format!("duplicate field `{}`", field.name)) - .at(field.name.clone())); - } - } - Ok(Self { fields }) + /// Starts decoding record fields or named operation arguments. + pub fn new(fields: BTreeMap) -> Self { + Self { fields } } /// Decodes a required field. @@ -668,17 +778,15 @@ pub mod __private { /// Rejects any fields that were not consumed by generated code. pub fn finish(self) -> Result<(), CodecError> { - if let Some(field) = self.fields.first() { - Err(CodecError::new(format!("unknown field `{}`", field.name)) - .at(field.name.clone())) + if let Some(name) = self.fields.keys().next() { + Err(CodecError::new(format!("unknown field `{name}`")).at(name.clone())) } else { Ok(()) } } fn take(&mut self, name: &str) -> Option { - let index = self.fields.iter().position(|field| field.name == name)?; - Some(self.fields.remove(index).value) + self.fields.remove(name) } } } diff --git a/crates/wip-interface/tests/codecs.rs b/crates/wip-interface/tests/codecs.rs index b8dc088..480f404 100644 --- a/crates/wip-interface/tests/codecs.rs +++ b/crates/wip-interface/tests/codecs.rs @@ -1,8 +1,7 @@ -use wip_interface::wip_protocol::{ - EnumDeclaration, JsonMember, JsonValue, NamedValue, RecordDeclaration, TypeDeclaration, - TypeExpr, UnionDeclaration, Value, -}; -use wip_interface::{Bytes, Entry, WipType}; +use std::collections::BTreeMap; + +use wip_interface::wip_protocol::{TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, Value}; +use wip_interface::{Bytes, Entry, JsonMember, JsonValue, WipType}; /// A request record. #[derive(Debug, Clone, PartialEq, WipType)] @@ -11,8 +10,8 @@ struct Request { title: String, /// Optional priority. priority: Option, - /// Related entries. - related: Vec>, + /// Related Worldspace entries. + related: Vec, /// Opaque bytes. body: Bytes, /// Arbitrary metadata. @@ -38,33 +37,53 @@ struct Receipt { /// Application-level publication outcome. #[derive(Debug, Clone, PartialEq, WipType)] enum PublishOutcome { + /// Work has not completed and carries no payload. + Pending, /// Application success. Published(Receipt), /// Domain rejection, not a host failure. Rejected(String), } +fn field<'a>(record: &'a BTreeMap, name: &str) -> &'a Value { + record + .get(name) + .unwrap_or_else(|| panic!("missing `{name}`")) +} + #[test] -fn supported_values_round_trip() { +fn primitive_unit_record_list_entry_and_json_values_round_trip() { + assert_eq!(().encode().unwrap(), Value::Unit); + assert_eq!(<()>::decode(Value::Unit).unwrap(), ()); + assert_eq!(<()>::type_expr(), TypeExpr::Unit); + let request = Request { title: "hello".into(), priority: None, - related: vec![Entry { - key: "/articles/1".into(), - value: true, - }], + related: vec![Entry::new("/articles/1").unwrap()], body: Bytes(vec![1, 2, 3]), metadata: JsonValue::Object(vec![JsonMember { name: "source".into(), - value: JsonValue::String("test".into()), + value: JsonValue::Array(vec![JsonValue::String("test".into()), JsonValue::Null]), }]), }; let encoded = request.clone().encode().unwrap(); let Value::Record(fields) = &encoded else { - panic!("record derive must encode a protocol record") + panic!("record derive must encode a raw protocol record") }; - assert!(fields.iter().all(|field| field.name != "priority")); + assert!(!fields.contains_key("priority")); + assert_eq!( + field(fields, "related"), + &Value::List(vec![Value::String("/articles/1".into())]) + ); + assert_eq!( + field(fields, "metadata"), + &Value::Record(BTreeMap::from([( + "source".into(), + Value::List(vec![Value::String("test".into()), Value::Unit]), + )])) + ); assert_eq!(Request::decode(encoded).unwrap(), request); let number = 42.5_f64; @@ -77,92 +96,136 @@ fn supported_values_round_trip() { f32::decode(narrow_number.encode().unwrap()).unwrap(), narrow_number ); +} +#[test] +fn enums_are_strings_and_unions_are_discriminated_records() { for color in [Color::Red, Color::Blue] { let encoded = color.clone().encode().unwrap(); - let decoded = Color::decode(encoded).unwrap(); - assert_eq!(decoded, color); + assert!(matches!(encoded, Value::String(_))); + assert_eq!(Color::decode(encoded).unwrap(), color); } + let pending = PublishOutcome::Pending.encode().unwrap(); + assert_eq!( + pending, + Value::Record(BTreeMap::from([( + UNION_CASE_FIELD.into(), + Value::String("Pending".into()), + )])) + ); + + let published = PublishOutcome::Published(Receipt { number: 7 }) + .encode() + .unwrap(); + let Value::Record(fields) = &published else { + panic!("union must encode as a record") + }; + assert_eq!( + fields.get(UNION_CASE_FIELD), + Some(&Value::String("Published".into())) + ); + assert!(matches!( + fields.get(UNION_VALUE_FIELD), + Some(Value::Record(_)) + )); + for outcome in [ + PublishOutcome::Pending, PublishOutcome::Published(Receipt { number: 7 }), PublishOutcome::Rejected("policy".into()), ] { let encoded = outcome.clone().encode().unwrap(); - let decoded = PublishOutcome::decode(encoded).unwrap(); - assert_eq!(decoded, outcome); + assert_eq!(PublishOutcome::decode(encoded).unwrap(), outcome); } } #[test] -fn derived_declarations_include_docs_optionality_and_dependencies() { +fn derived_declarations_use_canonical_named_type_expressions() { let mut declarations = Vec::new(); Request::collect_declarations(&mut declarations); Color::collect_declarations(&mut declarations); PublishOutcome::collect_declarations(&mut declarations); - let TypeDeclaration::Record(RecordDeclaration { - documentation, - fields, - .. - }) = &declarations[0] - else { + let request = declarations + .iter() + .find(|declaration| declaration.name == "Request") + .unwrap(); + assert_eq!( + request.documentation.as_ref().unwrap().summary, + "A request record." + ); + let TypeExpr::Record { fields } = &request.definition else { panic!("Request must be a record declaration") }; - assert_eq!(documentation.as_ref().unwrap().summary, "A request record."); assert_eq!( fields[0].documentation.as_ref().unwrap().summary, "Required title." ); - assert!(!fields[0].optional); - assert!(fields[1].optional); - assert_eq!(fields[1].ty, TypeExpr::Integer); + assert!(fields[0].required); + assert!(!fields[1].required); + assert_eq!(fields[1].r#type, TypeExpr::Integer); assert_eq!( - fields[2].ty, - TypeExpr::List(Box::new(TypeExpr::Entry { - key: Box::new(TypeExpr::String), - value: Box::new(TypeExpr::Boolean), - })) + fields[2].r#type, + TypeExpr::List { + items: Box::new(TypeExpr::Entry), + } ); let color = declarations .iter() - .find(|declaration| declaration.name() == "Color") + .find(|declaration| declaration.name == "Color") .unwrap(); - let TypeDeclaration::Enum(EnumDeclaration { variants, .. }) = color else { + let TypeExpr::Enum { cases } = &color.definition else { panic!("Color must be an enum declaration") }; assert_eq!( - variants[0].documentation.as_ref().unwrap().summary, + cases[0].documentation.as_ref().unwrap().summary, "Warm red." ); let outcome = declarations .iter() - .find(|declaration| declaration.name() == "PublishOutcome") + .find(|declaration| declaration.name == "PublishOutcome") .unwrap(); - let TypeDeclaration::Union(UnionDeclaration { variants, .. }) = outcome else { + let TypeExpr::Union { cases } = &outcome.definition else { panic!("PublishOutcome must be a union declaration") }; - assert_eq!(variants[0].ty, TypeExpr::Named("Receipt".into())); + assert_eq!(cases[0].payload, None); + assert_eq!( + cases[1].payload, + Some(TypeExpr::Named { + name: "Receipt".into(), + }) + ); assert!( declarations .iter() - .any(|declaration| declaration.name() == "Receipt") + .any(|declaration| declaration.name == "Receipt") ); } #[test] -fn codecs_reject_invalid_boundaries() { +fn codecs_reject_invalid_boundaries_and_json_bytes() { assert!(i64::MAX.encode().is_err()); assert!(f64::NAN.encode().is_err()); - assert!(Request::decode(Value::Record(vec![])).is_err()); + assert!(Request::decode(Value::Record(BTreeMap::new())).is_err()); assert!( - Request::decode(Value::Record(vec![NamedValue { - name: "unknown".into(), - value: Value::String("value".into()), - }])) + Request::decode(Value::Record(BTreeMap::from([( + "unknown".into(), + Value::String("value".into()), + )]))) + .is_err() + ); + assert!(Color::decode(Value::String("Green".into())).is_err()); + assert!(Entry::new("articles/1").is_err()); + assert!(Entry::decode(Value::String("/articles/../1".into())).is_err()); + assert!(JsonValue::decode(Value::Bytes(vec![1])).is_err()); + assert!( + JsonValue::decode(Value::Record(BTreeMap::from([( + "nested".into(), + Value::List(vec![Value::Bytes(vec![1])]), + )]))) .is_err() ); - assert!(Color::decode(Value::Enum("Green".into())).is_err()); } diff --git a/crates/wip-interface/tests/interface.rs b/crates/wip-interface/tests/interface.rs index 6779550..e96f3b8 100644 --- a/crates/wip-interface/tests/interface.rs +++ b/crates/wip-interface/tests/interface.rs @@ -1,4 +1,6 @@ -use wip_interface::wip_protocol::{NamedValue, TypeDeclaration, TypeExpr, Value}; +use std::collections::BTreeMap; + +use wip_interface::wip_protocol::{INTERFACE_FORMAT_V1, TypeExpr, Value}; use wip_interface::{DispatchError, HostError, OperationResult, WipType, interface}; /// Publication request. @@ -36,7 +38,7 @@ pub trait Catalog { note: Option, ) -> OperationResult; - /// Checks connectivity without returning an application value. + /// Checks connectivity without returning application data. fn ping(&self) -> OperationResult<()>; /// Demonstrates a host/runtime failure. @@ -88,36 +90,29 @@ impl Catalog for RejectingCatalog { } } -fn named(name: &str, value: Value) -> NamedValue { - NamedValue { - name: name.into(), - value, - } -} - -fn publish_arguments(with_note: bool) -> Vec { - let mut arguments = vec![named( - "request", +fn publish_arguments(with_note: bool) -> BTreeMap { + let mut arguments = BTreeMap::from([( + "request".into(), PublishRequest { title: "example".into(), } .encode() .unwrap(), - )]; + )]); if with_note { - arguments.push(named("note", Value::String("audit".into()))); + arguments.insert("note".into(), Value::String("audit".into())); } arguments } #[test] -fn descriptor_is_generated_only_from_the_trait_declaration() { +fn descriptor_is_canonical_and_generated_only_from_the_trait_declaration() { let first = CatalogDefinition::descriptor(); let second = CatalogDefinition::descriptor(); assert_eq!(first, second); first.validate().unwrap(); - assert_eq!(first.name, "Catalog"); + assert_eq!(first.format, INTERFACE_FORMAT_V1); assert_eq!( first.documentation.as_ref().unwrap().summary, "Catalog editing capability." @@ -139,81 +134,82 @@ fn descriptor_is_generated_only_from_the_trait_declaration() { .summary, "Item to publish." ); - assert!(!publish.parameters[0].optional); - assert!(publish.parameters[1].optional); + assert!(publish.parameters[0].required); + assert!(!publish.parameters[1].required); assert_eq!( - publish - .returns - .as_ref() - .unwrap() - .documentation - .as_ref() - .unwrap() - .summary, + publish.returns.documentation.as_ref().unwrap().summary, "The application publication outcome." ); assert_eq!( - publish.returns.as_ref().unwrap().ty, - TypeExpr::Named("PublishOutcome".into()) + publish.returns.r#type, + TypeExpr::Named { + name: "PublishOutcome".into(), + } ); - assert!(first.operations[1].returns.is_none()); + assert_eq!(first.operations[1].returns.r#type, TypeExpr::Unit); assert!( first - .declarations + .types .iter() - .any(|declaration| matches!(declaration, TypeDeclaration::Record(_))) + .any(|declaration| matches!(declaration.definition, TypeExpr::Record { .. })) ); assert!( first - .declarations + .types .iter() - .any(|declaration| matches!(declaration, TypeDeclaration::Union(_))) + .any(|declaration| matches!(declaration.definition, TypeExpr::Union { .. })) ); - // Both implementation types satisfy the same generated dispatch boundary; - // neither can affect the descriptor shape. + // Both implementation types satisfy one immutable generated definition; + // implementation selection cannot contribute fields to the descriptor. fn accepts_definition(_: &T) {} accepts_definition(&AcceptingCatalog); accepts_definition(&RejectingCatalog); + assert_eq!(CatalogDefinition::descriptor(), first); } #[test] -fn generated_argument_decoder_and_result_encoder_round_trip() { +fn generated_argument_decoder_and_result_encoder_use_raw_values() { let decoded = CatalogPublishArguments::decode(publish_arguments(false)).unwrap(); assert_eq!(decoded.request.title, "example"); assert_eq!(decoded.note, None); - let encoded = CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())) - .unwrap() - .unwrap(); + let encoded = + CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())).unwrap(); assert_eq!( PublishOutcome::decode(encoded).unwrap(), PublishOutcome::Rejected("domain".into()) ); + assert_eq!( + CatalogPingArguments::encode_result(()).unwrap(), + Value::Unit + ); } #[test] fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() { - let value = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)) - .unwrap() - .unwrap(); + let value = + CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)).unwrap(); assert_eq!( PublishOutcome::decode(value).unwrap(), PublishOutcome::Published(true) ); assert_eq!( - CatalogDefinition::dispatch(&AcceptingCatalog, "ping", vec![]).unwrap(), - None + CatalogDefinition::dispatch(&AcceptingCatalog, "ping", BTreeMap::new()).unwrap(), + Value::Unit ); - let invalid = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", vec![]).unwrap_err(); + let invalid = + CatalogDefinition::dispatch(&AcceptingCatalog, "publish", BTreeMap::new()).unwrap_err(); assert!(matches!(invalid, DispatchError::InvalidArguments(_))); - let host = CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", vec![]).unwrap_err(); + let host = + CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", BTreeMap::new()).unwrap_err(); assert!(matches!(host, DispatchError::Host(error) if error.message() == "backend unavailable")); - let unknown = CatalogDefinition::dispatch(&AcceptingCatalog, "missing", vec![]).unwrap_err(); + let unknown = + CatalogDefinition::dispatch(&AcceptingCatalog, "missing", BTreeMap::new()).unwrap_err(); assert!(matches!(unknown, DispatchError::UnknownOperation(name) if name == "missing")); } @@ -245,12 +241,12 @@ fn distinct_operation_names_always_generate_distinct_public_items() { assert_eq!(descriptor.operations[0].name, "item"); assert_eq!(descriptor.operations[1].name, "item_"); - let first = DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", vec![]) - .unwrap() - .unwrap(); - let second = DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", vec![]) - .unwrap() - .unwrap(); + let first = + DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", BTreeMap::new()) + .unwrap(); + let second = + DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", BTreeMap::new()) + .unwrap(); assert_eq!(first, Value::String("item".into())); assert_eq!(second, Value::String("item_".into())); } diff --git a/crates/wip-interface/tests/ui/mixed_enum.rs b/crates/wip-interface/tests/ui/mixed_enum.rs index 6a1021a..bd2f246 100644 --- a/crates/wip-interface/tests/ui/mixed_enum.rs +++ b/crates/wip-interface/tests/ui/mixed_enum.rs @@ -1,9 +1,9 @@ use wip_interface::WipType; #[derive(WipType)] -enum MixedEnum { +enum UnsupportedEnum { Empty, - Payload(String), + Payload { value: String }, } fn main() {} diff --git a/crates/wip-interface/tests/ui/mixed_enum.stderr b/crates/wip-interface/tests/ui/mixed_enum.stderr index 32ec315..951535f 100644 --- a/crates/wip-interface/tests/ui/mixed_enum.stderr +++ b/crates/wip-interface/tests/ui/mixed_enum.stderr @@ -1,5 +1,5 @@ -error: a `WipType` enum must contain either only unit variants or only single-field tuple variants; mixed and named-field variants are unsupported - --> tests/ui/mixed_enum.rs:4:1 +error: a `WipType` enum must contain unit variants and/or single-field tuple variants; named-field and multi-field variants are unsupported + --> tests/ui/mixed_enum.rs:6:5 | -4 | enum MixedEnum { - | ^^^^ +6 | Payload { value: String }, + | ^^^^^^^ diff --git a/crates/wip-interface/tests/ui/unsupported_type.stderr b/crates/wip-interface/tests/ui/unsupported_type.stderr index 22de413..cc3b163 100644 --- a/crates/wip-interface/tests/ui/unsupported_type.stderr +++ b/crates/wip-interface/tests/ui/unsupported_type.stderr @@ -4,16 +4,11 @@ error[E0277]: the trait bound `(std::string::String, std::string::String): WipTy 5 | fn load(&self, pair: (String, String)) -> OperationResult; | ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)` | - = help: the following other types implement trait `WipType`: - JsonValue - Vec - bool - f32 - f64 - i128 - i16 - i32 - and $N others +help: the trait `WipType` is implemented for `()` + --> src/lib.rs + | + | impl WipType for () { + | ^^^^^^^^^^^^^^^^^^^ error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied --> tests/ui/unsupported_type.rs:5:26 @@ -24,16 +19,11 @@ error[E0277]: the trait bound `(std::string::String, std::string::String): WipTy 5 | fn load(&self, pair: (String, String)) -> OperationResult; | ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)` | - = help: the following other types implement trait `WipType`: - JsonValue - Vec - bool - f32 - f64 - i128 - i16 - i32 - and $N others +help: the trait `WipType` is implemented for `()` + --> src/lib.rs + | + | impl WipType for () { + | ^^^^^^^^^^^^^^^^^^^ note: required by a bound in `wip_interface::__private::RecordDecoder::required` --> src/lib.rs | diff --git a/src/lib.rs b/src/lib.rs index a13758c..e93d44b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,18 +22,17 @@ #![deny(missing_docs)] +use std::collections::BTreeMap; use std::error::Error; use std::io; use wip_host::{ - CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, PublishedObject, - Validator, WipObject, + CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, ObjectId, + PublishedObject, Validator, WipObject, }; use wip_interface as wip; use wip_interface::WipType; -use wip_protocol::{ - InterfaceObservation, NamedValue, ObjectId, OperationOutcome, OperationResult, OperationTarget, -}; +use wip_protocol::ProtocolError; const OBJECT_ID: &str = "article-service"; const OBJECT_VALIDATOR: &[u8] = b"object-v1"; @@ -114,10 +113,10 @@ pub trait Diagnostics { 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, + /// Descriptor format observed for the catalog interface. + pub catalog_descriptor_format: String, + /// Descriptor format observed for the audit interface. + pub audit_descriptor_format: String, /// Typed successful application result decoded from a validated `Value`. pub published: PublishOutcome, /// Typed application-domain rejection decoded from a validated `Value`. @@ -128,8 +127,8 @@ pub struct DemoReport { 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, + /// A protocol-level failure built from the host failure. + pub protocol_failure: ProtocolError, } /// Runs the complete end-to-end example. @@ -179,14 +178,8 @@ pub fn run() -> Result> { .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()?; + catalog_observation.descriptor.validate()?; + audit_observation.descriptor.validate()?; let published = dispatch_publish( &publication, @@ -211,13 +204,18 @@ pub fn run() -> Result> { catalog_observation.reference.clone(), Some(Validator::new(CATALOG_VALIDATOR)), "status", - vec![], + BTreeMap::new(), ), "catalog status", )?; let audit_status = dispatch_string( &publication, - request(audit_observation.reference.clone(), None, "status", vec![]), + request( + audit_observation.reference.clone(), + None, + "status", + BTreeMap::new(), + ), "audit status", )?; @@ -225,7 +223,7 @@ pub fn run() -> Result> { catalog_observation.reference.clone(), Some(Validator::new(CATALOG_VALIDATOR)), "backend_status", - vec![], + BTreeMap::new(), )) { Ok(_) => { return Err(io::Error::other( @@ -242,20 +240,12 @@ pub fn run() -> Result> { )) .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), - }; + let protocol_failure = host_error.protocol_error(); Ok(DemoReport { published_interfaces: object.interfaces, - catalog_descriptor: catalog_observation.descriptor.name, - audit_descriptor: audit_observation.descriptor.name, + catalog_descriptor_format: catalog_observation.descriptor.format, + audit_descriptor_format: audit_observation.descriptor.format, published, rejected, catalog_status, @@ -275,26 +265,19 @@ fn dispatch_publish( catalog.clone(), Some(Validator::new(CATALOG_VALIDATOR)), "publish", - vec![NamedValue { - name: "request".into(), - value: encoded_request, - }], + BTreeMap::from([("request".into(), encoded_request)]), ))?; - let value = result - .value - .ok_or_else(|| io::Error::other("publish returned no application value"))?; + let value = result.value; Ok(PublishOutcome::decode(value)?) } fn dispatch_string( publication: &PublishedObject, request: CallRequest, - context: &str, + _context: &str, ) -> Result> { let result = publication.dispatch(request)?; - let value = result - .value - .ok_or_else(|| io::Error::other(format!("{context} returned no value")))?; + let value = result.value; Ok(String::decode(value)?) } @@ -302,7 +285,7 @@ fn request( interface: InterfaceRef, interface_validator: Option, operation: &str, - arguments: Vec, + arguments: BTreeMap, ) -> CallRequest { CallRequest { object: ObjectId(OBJECT_ID.into()), @@ -383,7 +366,7 @@ mod tests { use super::*; use wip_host::CallError; use wip_interface::{CodecError, TypeDeclaration, TypeExpr}; - use wip_protocol::{ProtocolErrorCategory, Value}; + use wip_protocol::{ProtocolErrorCode, Value}; /// A deliberately inconsistent custom codec used to prove result validation. pub struct InvalidValue; @@ -444,9 +427,9 @@ mod tests { } } - fn assert_failure(error: CallError, kind: CallErrorKind, category: ProtocolErrorCategory) { + fn assert_failure(error: CallError, kind: CallErrorKind, code: ProtocolErrorCode) { assert_eq!(error.kind(), kind); - assert_eq!(error.protocol_error().category, category); + assert_eq!(error.protocol_error().code, code); } #[test] @@ -459,14 +442,14 @@ mod tests { fixture.catalog.clone(), Some(Validator::new(CATALOG_VALIDATOR)), "status", - vec![], + BTreeMap::new(), ), "catalog status", ) .unwrap(); let audit = dispatch_string( &fixture.publication, - request(fixture.audit.clone(), None, "status", vec![]), + request(fixture.audit.clone(), None, "status", BTreeMap::new()), "audit status", ) .unwrap(); @@ -475,12 +458,17 @@ mod tests { let not_exposed = fixture .publication - .dispatch(request(fixture.diagnostics, None, "diagnostics", vec![])) + .dispatch(request( + fixture.diagnostics, + None, + "diagnostics", + BTreeMap::new(), + )) .unwrap_err(); assert_failure( not_exposed, CallErrorKind::InterfaceMismatch, - ProtocolErrorCategory::InterfaceNotFound, + ProtocolErrorCode::InterfaceMismatch, ); } @@ -492,20 +480,20 @@ mod tests { fixture.catalog.clone(), Some(Validator::new(CATALOG_VALIDATOR)), "status", - vec![], + BTreeMap::new(), ); wrong_object.object = ObjectId("other-object".into()); assert_failure( fixture.publication.dispatch(wrong_object).unwrap_err(), CallErrorKind::ObjectNotFound, - ProtocolErrorCategory::ObjectNotFound, + ProtocolErrorCode::NotFound, ); let mut missing_object_validator = request( fixture.catalog.clone(), Some(Validator::new(CATALOG_VALIDATOR)), "status", - vec![], + BTreeMap::new(), ); missing_object_validator.object_validator = None; assert_failure( @@ -514,14 +502,14 @@ mod tests { .dispatch(missing_object_validator) .unwrap_err(), CallErrorKind::ValidatorRequired, - ProtocolErrorCategory::InvalidRequest, + ProtocolErrorCode::InvalidRequest, ); let mut stale_object_validator = request( fixture.catalog.clone(), Some(Validator::new(CATALOG_VALIDATOR)), "status", - vec![], + BTreeMap::new(), ); stale_object_validator.object_validator = Some(Validator::new(b"stale-object")); assert_failure( @@ -530,16 +518,21 @@ mod tests { .dispatch(stale_object_validator) .unwrap_err(), CallErrorKind::ValidatorMismatch, - ProtocolErrorCategory::InvalidRequest, + ProtocolErrorCode::InvalidRequest, ); assert_failure( fixture .publication - .dispatch(request(fixture.catalog.clone(), None, "status", vec![])) + .dispatch(request( + fixture.catalog.clone(), + None, + "status", + BTreeMap::new(), + )) .unwrap_err(), CallErrorKind::InterfaceValidatorRequired, - ProtocolErrorCategory::InvalidRequest, + ProtocolErrorCode::InvalidRequest, ); assert_failure( @@ -549,11 +542,11 @@ mod tests { fixture.catalog.clone(), Some(Validator::new(b"stale-interface")), "status", - vec![], + BTreeMap::new(), )) .unwrap_err(), CallErrorKind::InterfaceValidatorMismatch, - ProtocolErrorCategory::InvalidRequest, + ProtocolErrorCode::InvalidRequest, ); assert_failure( @@ -563,11 +556,11 @@ mod tests { fixture.catalog.clone(), Some(Validator::new(CATALOG_VALIDATOR)), "missing", - vec![], + BTreeMap::new(), )) .unwrap_err(), CallErrorKind::OperationNotFound, - ProtocolErrorCategory::OperationNotFound, + ProtocolErrorCode::OperationNotFound, ); assert_failure( @@ -577,14 +570,11 @@ mod tests { fixture.catalog.clone(), Some(Validator::new(CATALOG_VALIDATOR)), "publish", - vec![NamedValue { - name: "request".into(), - value: Value::String("not a record".into()), - }], + BTreeMap::from([("request".into(), Value::String("not a record".into()))]), )) .unwrap_err(), CallErrorKind::InvalidArguments, - ProtocolErrorCategory::InvalidArguments, + ProtocolErrorCode::InvalidArguments, ); assert_failure( @@ -594,11 +584,11 @@ mod tests { fixture.catalog, Some(Validator::new(CATALOG_VALIDATOR)), "backend_status", - vec![], + BTreeMap::new(), )) .unwrap_err(), CallErrorKind::Implementation, - ProtocolErrorCategory::Internal, + ProtocolErrorCode::Internal, ); } @@ -615,13 +605,13 @@ mod tests { broken.reference().clone(), None, "broken_result", - vec![], + BTreeMap::new(), )) .unwrap_err(); assert_failure( error, CallErrorKind::InvalidResult, - ProtocolErrorCategory::Internal, + ProtocolErrorCode::Internal, ); } } diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index 546a6c6..1a8fc73 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -1,5 +1,5 @@ use wip_host::CallErrorKind; -use wip_protocol::{OperationOutcome, ProtocolErrorCategory}; +use wip_protocol::{INTERFACE_FORMAT_V1, ProtocolErrorCode}; use wip_public_api_example::{PublishOutcome, run}; #[test] @@ -7,8 +7,8 @@ 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.catalog_descriptor_format, INTERFACE_FORMAT_V1); + assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1); assert_eq!( report.published, PublishOutcome::Published("article-1".into()) @@ -20,8 +20,5 @@ fn public_crates_support_the_complete_end_to_end_scenario() { 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 - )); + assert_eq!(report.protocol_failure.code, ProtocolErrorCode::Internal); }