diff --git a/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index b6c4d371..c832bfc3 100644 --- a/docs/local-e2e-testing.md +++ b/docs/local-e2e-testing.md @@ -275,16 +275,15 @@ Fix: turn the link into a fully-qualified path (`super::T`, `crate::vXY::T`, or just drop the link to a sibling that won't resolve across the doc-namespace boundary). Re-run `./scripts/codegen.sh`. -### V0.2-only wrapper has no V1 variant +### Payloadless request has no payload arm -Symptom: codegen omits a V1 arm for an enum like `HostGetUserIdRequest`. -Wire-table loop test passes a smaller `` than expected. +Symptom: codegen omits a payload arm for a request enum like +`HostGetUserIdRequest`. The wire-table loop test passes a smaller `` +than expected. -This is intentional. V0.2-only methods (`host_get_user_id`, -`host_chat_create_simple_group`, all `EntropyDerivation`, all `Payment`) -have only the `V2` variant in their versioned wrapper because no V0.1 -host ever spoke them. `IntoVersion::into_version(Version::V1)` returns -`Err(())` for these. +This is intentional. A request that takes no arguments declares a +payloadless `V1` variant (`pub enum HostGetUserIdRequest { V1 }`), so +there is no inner type for codegen to emit. ## Definition of done diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 7f585342..2e8aa459 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -257,10 +257,10 @@ pub fn extract_api(krate: &Crate) -> Result { let mut traits = Vec::new(); for (name, candidates) in trait_candidates { - // `Versioned` is a runtime-helper trait on the wrapper enums, not a - // protocol-method trait. The codegen only cares about the protocol - // surface (TrUAPI methods); skip anything declared outside - // `truapi::api::*`. + // `Versioned`, `IntoLatest`, and `FromLatest` are runtime-helper traits + // on the wrapper enums, not protocol-method traits. The codegen only + // cares about the protocol surface (TrUAPI methods); skip anything + // declared outside `truapi::api::*`. let candidate = select_candidate(&name, &candidates)?; if !candidate.path.iter().any(|s| s == "api") { continue; diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index 20d063de..14c919f0 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -1,6 +1,10 @@ //! Proc-macros for TrUAPI trait annotations. //! -//! The single attribute exposed is [`wire`], which marks a trait method with +//! `versioned_type!` is a function-like macro that generates versioned message +//! envelopes: the `Vn` enums (with SCALE codec indices) plus their +//! `Versioned`/`IntoLatest`/`FromLatest` impls from `truapi::versioned`. +//! +//! The `wire` attribute marks a trait method with //! its wire-protocol discriminant ids. The ids appear on the wire as the u8 discriminant in the //! `Struct { request_id: str, payload: Enum() }` envelope; method //! ordering becomes part of the wire protocol. @@ -17,9 +21,13 @@ //! rustdoc through the only attribute that is always preserved verbatim. use proc_macro::TokenStream; +use proc_macro2::Literal; use quote::quote; use syn::parse::{Parse, ParseStream}; -use syn::{Ident, ItemFn, LitInt, Token, TraitItemFn, parse_macro_input}; +use syn::{ + Attribute, Ident, ItemFn, LitInt, Token, TraitItemFn, Type, Visibility, braced, + parse_macro_input, +}; #[derive(Default)] struct WireArgs { @@ -138,3 +146,222 @@ fn wire_tags(args: &WireArgs) -> Vec { .filter_map(|(name, value)| value.map(|id| format!("@wire_{name}={id}"))) .collect() } + +/// One sequence of versioned envelope declarations passed to `versioned_type!`. +struct VersionedInput { + enums: Vec, +} + +impl Parse for VersionedInput { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut enums = Vec::new(); + while !input.is_empty() { + enums.push(input.parse()?); + } + Ok(Self { enums }) + } +} + +/// A single `[vis] enum Name { V1 => Ty, ... }` declaration. +struct VersionedEnum { + attrs: Vec, + vis: Visibility, + name: Ident, + variants: Vec, +} + +impl Parse for VersionedEnum { + fn parse(input: ParseStream<'_>) -> syn::Result { + let attrs = input.call(Attribute::parse_outer)?; + let vis: Visibility = input.parse()?; + input.parse::()?; + let name: Ident = input.parse()?; + + let body; + braced!(body in input); + let mut variants = Vec::new(); + while !body.is_empty() { + variants.push(body.parse()?); + if body.peek(Token![,]) { + body.parse::()?; + } else { + break; + } + } + + Ok(Self { + attrs, + vis, + name, + variants, + }) + } +} + +/// A single `Vn` or `Vn => Ty` variant. +struct VersionedVariant { + attrs: Vec, + ident: Ident, + ty: Option, +} + +impl Parse for VersionedVariant { + fn parse(input: ParseStream<'_>) -> syn::Result { + let attrs = input.call(Attribute::parse_outer)?; + let ident: Ident = input.parse()?; + let ty = if input.peek(Token![=>]) { + input.parse::]>()?; + Some(input.parse()?) + } else { + None + }; + Ok(Self { attrs, ident, ty }) + } +} + +/// Parse the `Vn` version number from a variant identifier. +fn variant_version(ident: &Ident) -> syn::Result { + let name = ident.to_string(); + let err = || syn::Error::new(ident.span(), "variant must be named `Vn` where n is a u8"); + name.strip_prefix('V') + .ok_or_else(err)? + .parse::() + .map_err(|_| err()) +} + +/// Generate versioned message envelopes. +/// +/// ```ignore +/// versioned_type! { +/// pub enum HostFooRequest { V1 => v01::HostFooRequest } +/// pub enum HostFooResponse { V1 } +/// } +/// ``` +/// +/// Each declaration becomes a SCALE enum with positional codec indices and an +/// `impl Versioned` exposing `Latest`, `LATEST`, and `version()`. Single-version +/// envelopes also get trivial `IntoLatest`/`FromLatest` impls; multi-version +/// envelopes leave those to be written by hand, since the conversion is bespoke. +/// +/// The declared visibility (`pub`, `pub(crate)`, or none) carries through to the +/// generated enum. +/// +/// The generated impls name `crate::versioned::*` traits, so invoke this from +/// within the `truapi` crate. +#[proc_macro] +pub fn versioned_type(item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as VersionedInput); + match expand_versioned(&input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +fn expand_versioned(input: &VersionedInput) -> syn::Result { + let mut out = proc_macro2::TokenStream::new(); + for enum_def in &input.enums { + out.extend(expand_versioned_enum(enum_def)?); + } + Ok(out) +} + +fn expand_versioned_enum(def: &VersionedEnum) -> syn::Result { + let VersionedEnum { + attrs, + vis, + name, + variants, + } = def; + + if variants.is_empty() { + return Err(syn::Error::new( + name.span(), + "versioned enum needs at least one variant", + )); + } + + let mut variant_defs = Vec::new(); + let mut version_arms = Vec::new(); + for (i, variant) in variants.iter().enumerate() { + let expected = i + 1; + let version = variant_version(&variant.ident)?; + if usize::from(version) != expected { + return Err(syn::Error::new( + variant.ident.span(), + format!("expected variant `V{expected}`; versions must be contiguous from 1"), + )); + } + + let index = Literal::u8_unsuffixed(i as u8); + let version_lit = Literal::u8_unsuffixed(version); + let vattrs = &variant.attrs; + let vident = &variant.ident; + match &variant.ty { + Some(ty) => { + variant_defs.push(quote! { #(#vattrs)* #[codec(index = #index)] #vident(#ty) }); + version_arms.push(quote! { Self::#vident(..) => #version_lit }); + } + None => { + variant_defs.push(quote! { #(#vattrs)* #[codec(index = #index)] #vident }); + version_arms.push(quote! { Self::#vident => #version_lit }); + } + } + } + + let doc = format!("Versioned envelope for [`{name}`]."); + let latest_lit = Literal::u8_unsuffixed(variants.len() as u8); + let latest_ty = match &variants.last().expect("checked non-empty").ty { + Some(ty) => quote! { #ty }, + None => quote! { () }, + }; + + let mut tokens = quote! { + #(#attrs)* + #[doc = #doc] + #[derive(Debug, Clone, PartialEq, Eq, parity_scale_codec::Encode, parity_scale_codec::Decode)] + #vis enum #name { + #(#variant_defs),* + } + + impl crate::versioned::Versioned for #name { + type Latest = #latest_ty; + const LATEST: u8 = #latest_lit; + fn version(&self) -> u8 { + match self { + #(#version_arms),* + } + } + } + }; + + if let [only] = &variants[..] { + let vident = &only.ident; + let (into_body, from_param, from_body) = match &only.ty { + Some(_) => ( + quote! { match self { Self::#vident(inner) => inner } }, + quote! { latest }, + quote! { Self::#vident(latest) }, + ), + None => ( + quote! { match self { Self::#vident => () } }, + quote! { _latest }, + quote! { Self::#vident }, + ), + }; + tokens.extend(quote! { + impl crate::versioned::IntoLatest for #name { + fn into_latest(self) -> Self::Latest { + #into_body + } + } + + impl crate::versioned::FromLatest for #name { + fn from_latest(#from_param: Self::Latest, _target: u8) -> Self { + #from_body + } + } + }); + } + + Ok(tokens) +} diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index 41f49af9..1d82fc62 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostAccountGetRequest { V1 => v01::HostAccountGetRequest } pub enum HostAccountGetResponse { V1 => v01::HostAccountGetResponse } pub enum HostAccountGetError { V1 => v01::HostAccountGetError } diff --git a/rust/crates/truapi/src/versioned/chain.rs b/rust/crates/truapi/src/versioned/chain.rs index eaa12131..b8a2ccb3 100644 --- a/rust/crates/truapi/src/versioned/chain.rs +++ b/rust/crates/truapi/src/versioned/chain.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum RemoteChainHeadFollowRequest { V1 => v01::RemoteChainHeadFollowRequest } pub enum RemoteChainHeadFollowItem { V1 => v01::RemoteChainHeadFollowItem } pub enum RemoteChainHeadHeaderRequest { V1 => v01::RemoteChainHeadHeaderRequest } diff --git a/rust/crates/truapi/src/versioned/chat.rs b/rust/crates/truapi/src/versioned/chat.rs index 44eb7324..5c821275 100644 --- a/rust/crates/truapi/src/versioned/chat.rs +++ b/rust/crates/truapi/src/versioned/chat.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostChatCreateRoomRequest { V1 => v01::HostChatCreateRoomRequest } pub enum HostChatCreateRoomResponse { V1 => v01::HostChatCreateRoomResponse } pub enum HostChatCreateRoomError { V1 => v01::HostChatCreateRoomError } diff --git a/rust/crates/truapi/src/versioned/coin_payment.rs b/rust/crates/truapi/src/versioned/coin_payment.rs index 6d6c894f..3e488a7f 100644 --- a/rust/crates/truapi/src/versioned/coin_payment.rs +++ b/rust/crates/truapi/src/versioned/coin_payment.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostCoinPaymentCreatePurseRequest { V1 => v01::HostCoinPaymentCreatePurseRequest } pub enum HostCoinPaymentCreatePurseResponse { V1 => v01::HostCoinPaymentCreatePurseResponse } pub enum HostCoinPaymentCreatePurseError { V1 => v01::CoinPaymentError } diff --git a/rust/crates/truapi/src/versioned/entropy.rs b/rust/crates/truapi/src/versioned/entropy.rs index 9eed9400..5027e4f0 100644 --- a/rust/crates/truapi/src/versioned/entropy.rs +++ b/rust/crates/truapi/src/versioned/entropy.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostDeriveEntropyRequest { V1 => v01::HostDeriveEntropyRequest } pub enum HostDeriveEntropyResponse { V1 => v01::HostDeriveEntropyResponse } pub enum HostDeriveEntropyError { V1 => v01::HostDeriveEntropyError } diff --git a/rust/crates/truapi/src/versioned/local_storage.rs b/rust/crates/truapi/src/versioned/local_storage.rs index 3ed0261c..708eb4c7 100644 --- a/rust/crates/truapi/src/versioned/local_storage.rs +++ b/rust/crates/truapi/src/versioned/local_storage.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostLocalStorageReadRequest { V1 => v01::HostLocalStorageReadRequest } pub enum HostLocalStorageReadResponse { V1 => v01::HostLocalStorageReadResponse } pub enum HostLocalStorageReadError { V1 => v01::HostLocalStorageReadError } diff --git a/rust/crates/truapi/src/versioned/mod.rs b/rust/crates/truapi/src/versioned/mod.rs index 9834e5c8..9da72067 100644 --- a/rust/crates/truapi/src/versioned/mod.rs +++ b/rust/crates/truapi/src/versioned/mod.rs @@ -1,93 +1,33 @@ //! Versioned request and response wrappers for the unified TrUAPI contract. +//! +//! A versioned envelope is a SCALE enum whose variants (`V1`, `V2`, ...) are +//! successive versions of one logical message, newest last. A server normalizes +//! incoming values to [`Versioned::Latest`] with [`IntoLatest`], handles them in +//! latest terms, then maps results back to the caller's version with +//! [`FromLatest`]. The envelopes themselves are generated by `versioned_type!`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Version { - /// Initial protocol version. - V1, -} +/// A versioned message envelope. +pub trait Versioned: Sized { + /// The newest version's payload. Handlers operate exclusively on this. + type Latest; -pub mod latest { - use super::Version; + /// Version number of the newest variant. + const LATEST: u8; - pub const VERSION: Version = Version::V1; + /// Version number of the variant currently held. + fn version(&self) -> u8; } -#[allow(clippy::result_unit_err)] -pub trait IntoVersion: Sized { - fn into_version(self, version: Version) -> Result; - - fn into_latest(self) -> Result { - self.into_version(latest::VERSION) - } +/// Upgrade a received envelope to its latest payload. Total by construction. +pub trait IntoLatest: Versioned { + /// Convert whatever version is held into the latest payload. + fn into_latest(self) -> Self::Latest; } -macro_rules! versioned_type { - ( - $( - $(#[$enum_meta:meta])* - pub enum $name:ident { - $($body:tt)* - } - )* - ) => { - $( - versioned_type! { - @one - $(#[$enum_meta])* - pub enum $name { - $($body)* - } - } - )* - }; - - ( - @one - $(#[$enum_meta:meta])* - pub enum $name:ident { - $(#[$variant_meta:meta])* - V1 => $v1:ty $(,)? - } - ) => { - $(#[$enum_meta])* - #[doc = concat!("Versioned envelope for [`", stringify!($name), "`].")] - #[derive(Debug, Clone, PartialEq, Eq, parity_scale_codec::Encode, parity_scale_codec::Decode)] - pub enum $name { - $(#[$variant_meta])* - #[codec(index = 0)] - V1($v1), - } - - impl $crate::versioned::IntoVersion for $name { - fn into_version(self, _version: $crate::versioned::Version) -> Result { - Ok(self) - } - } - }; - - ( - @one - $(#[$enum_meta:meta])* - pub enum $name:ident { - $(#[$variant_meta:meta])* - V1 $(,)? - } - ) => { - $(#[$enum_meta])* - #[doc = concat!("Versioned envelope for [`", stringify!($name), "`].")] - #[derive(Debug, Clone, PartialEq, Eq, parity_scale_codec::Encode, parity_scale_codec::Decode)] - pub enum $name { - $(#[$variant_meta])* - #[codec(index = 0)] - V1, - } - - impl $crate::versioned::IntoVersion for $name { - fn into_version(self, _version: $crate::versioned::Version) -> Result { - Ok(self) - } - } - }; +/// Downgrade a latest payload into the variant a peer at `target` understands. +pub trait FromLatest: Versioned { + /// Build the envelope for protocol version `target` (highest variant ≤ target). + fn from_latest(latest: Self::Latest, target: u8) -> Self; } pub mod account; @@ -110,6 +50,41 @@ pub mod theme; mod tests { use parity_scale_codec::{Decode, Encode}; + #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] + struct ProbeV1 { + a: u32, + } + + #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] + struct ProbeV2 { + b: Vec, + } + + truapi_macros::versioned_type! { + enum MultiVersionProbe { + V1 => ProbeV1, + V2 => ProbeV2, + } + } + + // Multi-version envelopes assign positional SCALE codec indices (V1 -> 0, + // V2 -> 1) and 1-based version numbers. + #[test] + fn multi_version_codec_indices_are_positional() { + use super::Versioned; + + let v1 = MultiVersionProbe::V1(ProbeV1 { a: 7 }); + let v2 = MultiVersionProbe::V2(ProbeV2 { + b: b"hello".to_vec(), + }); + + assert_eq!(v1.encode()[0], 0, "V1 encodes codec index 0"); + assert_eq!(v2.encode()[0], 1, "V2 encodes codec index 1"); + assert_eq!(v1.version(), 1); + assert_eq!(v2.version(), 2); + assert_eq!(MultiVersionProbe::LATEST, 2); + } + #[test] fn v1_discriminant_is_zero() { let v1 = super::permissions::HostDevicePermissionRequest::V1( diff --git a/rust/crates/truapi/src/versioned/notifications.rs b/rust/crates/truapi/src/versioned/notifications.rs index e2f19524..3a8b7c5e 100644 --- a/rust/crates/truapi/src/versioned/notifications.rs +++ b/rust/crates/truapi/src/versioned/notifications.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostPushNotificationRequest { V1 => v01::HostPushNotificationRequest } pub enum HostPushNotificationResponse { V1 => v01::HostPushNotificationResponse } pub enum HostPushNotificationError { V1 => v01::HostPushNotificationError } diff --git a/rust/crates/truapi/src/versioned/payment.rs b/rust/crates/truapi/src/versioned/payment.rs index c33c36b4..d252cf4e 100644 --- a/rust/crates/truapi/src/versioned/payment.rs +++ b/rust/crates/truapi/src/versioned/payment.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostPaymentBalanceSubscribeRequest { V1 => v01::HostPaymentBalanceSubscribeRequest } pub enum HostPaymentBalanceSubscribeItem { V1 => v01::HostPaymentBalanceSubscribeItem } pub enum HostPaymentBalanceSubscribeError { V1 => v01::HostPaymentBalanceSubscribeError } diff --git a/rust/crates/truapi/src/versioned/permissions.rs b/rust/crates/truapi/src/versioned/permissions.rs index 53e97079..721ce570 100644 --- a/rust/crates/truapi/src/versioned/permissions.rs +++ b/rust/crates/truapi/src/versioned/permissions.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { #[derive(derive_more::Display)] #[display("{_0}")] pub enum HostDevicePermissionRequest { V1 => v01::HostDevicePermissionRequest } diff --git a/rust/crates/truapi/src/versioned/preimage.rs b/rust/crates/truapi/src/versioned/preimage.rs index eaa81e16..61bf4b2f 100644 --- a/rust/crates/truapi/src/versioned/preimage.rs +++ b/rust/crates/truapi/src/versioned/preimage.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum RemotePreimageLookupSubscribeRequest { V1 => v01::RemotePreimageLookupSubscribeRequest } pub enum RemotePreimageLookupSubscribeItem { V1 => v01::RemotePreimageLookupSubscribeItem } pub enum RemotePreimageSubmitRequest { V1 => Vec } diff --git a/rust/crates/truapi/src/versioned/resource_allocation.rs b/rust/crates/truapi/src/versioned/resource_allocation.rs index 36466a10..7d69c24b 100644 --- a/rust/crates/truapi/src/versioned/resource_allocation.rs +++ b/rust/crates/truapi/src/versioned/resource_allocation.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostRequestResourceAllocationRequest { V1 => v01::HostRequestResourceAllocationRequest } pub enum HostRequestResourceAllocationResponse { V1 => v01::HostRequestResourceAllocationResponse } pub enum HostRequestResourceAllocationError { V1 => v01::ResourceAllocationError } diff --git a/rust/crates/truapi/src/versioned/signing.rs b/rust/crates/truapi/src/versioned/signing.rs index 718d5c0d..8c9acc6a 100644 --- a/rust/crates/truapi/src/versioned/signing.rs +++ b/rust/crates/truapi/src/versioned/signing.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostSignPayloadRequest { V1 => v01::HostSignPayloadRequest } pub enum HostSignPayloadResponse { V1 => v01::HostSignPayloadResponse } pub enum HostSignPayloadError { V1 => v01::HostSignPayloadError } diff --git a/rust/crates/truapi/src/versioned/statement_store.rs b/rust/crates/truapi/src/versioned/statement_store.rs index b9a6fe27..979885ba 100644 --- a/rust/crates/truapi/src/versioned/statement_store.rs +++ b/rust/crates/truapi/src/versioned/statement_store.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum RemoteStatementStoreSubscribeRequest { V1 => v01::RemoteStatementStoreSubscribeRequest } pub enum RemoteStatementStoreSubscribeItem { V1 => v01::RemoteStatementStoreSubscribeItem } pub enum RemoteStatementStoreCreateProofRequest { V1 => v01::RemoteStatementStoreCreateProofRequest } diff --git a/rust/crates/truapi/src/versioned/system.rs b/rust/crates/truapi/src/versioned/system.rs index 404465d8..0e8e65fe 100644 --- a/rust/crates/truapi/src/versioned/system.rs +++ b/rust/crates/truapi/src/versioned/system.rs @@ -2,7 +2,7 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostHandshakeRequest { V1 => v01::HostHandshakeRequest } pub enum HostHandshakeResponse { V1 } pub enum HostHandshakeError { V1 => v01::HostHandshakeError } diff --git a/rust/crates/truapi/src/versioned/theme.rs b/rust/crates/truapi/src/versioned/theme.rs index ee044934..cac165ea 100644 --- a/rust/crates/truapi/src/versioned/theme.rs +++ b/rust/crates/truapi/src/versioned/theme.rs @@ -2,6 +2,6 @@ use crate::v01; -versioned_type! { +truapi_macros::versioned_type! { pub enum HostThemeSubscribeItem { V1 => v01::HostThemeSubscribeItem } }