diff --git a/CLAUDE.md b/CLAUDE.md index 876792efe..2017a7167 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,9 @@ This repo is the single source of truth for the TrUAPI protocol. It vendors `dot rust/crates/ truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 (canonical) truapi-codegen/ rustdoc JSON → TypeScript client + Rust dispatcher - truapi-macros/ #[wire(id = N)] proc-macro + truapi-macros/ #[wire(id = N)] proc-macro; SsoWire derive and + #[sso_service] for truapi-server's inter-host SSO protocol + One implementation module per macro; lib.rs holds entry points truapi-platform/ Host syscall traits (storage, navigation, consent, ...) truapi-provider/ network provider backends (WebSocket RPC or smoldot light-client) truapi-server/ Rust runtime hosts implement; ships as WASM (browser/node) diff --git a/Cargo.lock b/Cargo.lock index 350163efb..92401e469 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5080,6 +5080,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-tuple" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f" + [[package]] name = "tempfile" version = "3.27.0" @@ -5093,6 +5099,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -5563,6 +5578,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", + "trybuild", ] [[package]] @@ -5680,6 +5696,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cabaa10be1917331a313866bd94526343e03c77bcf69144b62b072ad35d47c" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-tuple", + "termcolor", + "toml", +] + [[package]] name = "tungstenite" version = "0.21.0" diff --git a/README.md b/README.md index c7b032e4f..d6a684304 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full rust/crates/ truapi/ Rust traits, versioned envelopes, and latest payload re-exports truapi-codegen/ rustdoc JSON to TypeScript client + Rust dispatcher - truapi-macros/ #[wire(id = N)] proc-macro + truapi-macros/ TrUAPI wire annotations and inter-host SSO proc macros truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...) truapi-provider/ Network provider backends (WebSocket RPC or smoldot light-client) truapi-server/ Host runtime: dispatcher, typed SCALE logic, chain signing, WASM surface @@ -92,6 +92,8 @@ scripts/codegen.sh Regenerate the TS client from the Rust source scripts/battery.sh Run the generated battery against both headless CLI host roles ``` +See the [proc-macro guide](rust/crates/truapi-macros/README.md) for typed SSO handlers, their shared response envelope, and the macro implementation modules. + The Swift host adapter (the `TrUAPIHost` SPM package over the truapi-server UniFFI core) lives under [`ios/truapi-host/`](ios/truapi-host), with its SPM manifest at the repo root (`Package.swift`) so apps can consume it as a git-URL diff --git a/rust/crates/truapi-macros/Cargo.toml b/rust/crates/truapi-macros/Cargo.toml index 60c3fd122..7159536e4 100644 --- a/rust/crates/truapi-macros/Cargo.toml +++ b/rust/crates/truapi-macros/Cargo.toml @@ -13,5 +13,8 @@ proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } +[dev-dependencies] +trybuild = "1" + [lints] workspace = true diff --git a/rust/crates/truapi-macros/README.md b/rust/crates/truapi-macros/README.md new file mode 100644 index 000000000..534a72803 --- /dev/null +++ b/rust/crates/truapi-macros/README.md @@ -0,0 +1,80 @@ +# TrUAPI proc macros + +This crate provides TrUAPI wire annotations and versioned envelopes, plus +server-specific macros for inter-host SSO contracts. + +Each macro has its own implementation module. [`lib.rs`](src/lib.rs) contains +the thin public entry points, which Rust requires at the proc-macro crate root. + +| Macro | Input | Generated code | +| --- | --- | --- | +| [`service`](src/service.rs) | TrUAPI service trait | Required middleware metadata for codegen | +| [`wire`](src/wire.rs) | TrUAPI method | Wire IDs and flags for codegen | +| [`versioned_type!`](src/versioned_type.rs) | Versioned envelope declarations | SCALE enums and version conversion traits | +| [`SsoWire`](src/sso_wire.rs) | Hand-written `v1::RemoteMessage` enum | Request classification and wrapping, message names, and correlation helpers | +| [`sso_service`](src/sso_service.rs) | Dedicated inherent impl of SSO handlers | Request/response variant conversion, exhaustive dispatch, and handler reply conversion | + +## Handler contract + +Every method in the annotated impl is an endpoint. Its name selects a wire +request variant, its parameter declares the payload, and its return type names +the response's `Result` payload: + +```rust +pub type GetAccountAliasResponse = Result; + +#[truapi_macros::sso_service] +impl SigningHostSsoService { + async fn get_account_alias( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> GetAccountAliasResponse { + self.signing_host + .account_alias(&cx.call, &cx.session, request) + .await + } +} +``` + +The method `get_account_alias` selects `GetAccountAliasRequest`; parameter types +can be canonical payloads or generic wrappers without request aliases. +The return type's name selects the wire response +variant, including when two handlers share one variant. Distinct variants may +carry identical result types; conversion belongs to the request, so those +responses remain distinguishable. Constructors and helpers belong in a separate impl. + +Handler signatures expand to native async methods returning `SsoReply`. +Bodies return the named `Result` or an explicit reply with a local transcript +outcome. Shared Rust code adds `Response

{ responding_to, payload }`; +the generated request contract selects its wire variant. An inner async block +preserves `?` and early returns. + +The generated `dispatch(&self, session, message)` method classifies the message, +creates context from the supplied signing session, and exhaustively selects a +handler. Without a session it returns the response's typed disconnected error. +Shared reply finishing supplies correlation and defaults the transcript outcome +to success or error; handlers classify operation-specific outcomes. Missing +handlers, undeclared wire variants, and incompatible payloads fail compilation. + +The wire enum contains requests, responses, and disconnects in one SCALE tag +space. `SsoWire` projects its requests into `AnyRequest` through `classify()`. +Dispatch matches that request-only enum exhaustively, so a new wire request +cannot silently fall through without a handler. + +## Server integration + +These macros target contracts in `crate::host_logic::sso::{messages, wire}` and +`crate::runtime::{authority, sso_service}`. They are intended for invocation inside +`truapi-server`; the canonical `truapi` crate uses the other macros and has no +server runtime dependency. Wire encoding remains owned by the enum and payload +codec derives. Transport, consent, session revalidation, and business logic belong +to the server implementation. + +The [compiler tests](tests/sso.rs) exercise the macros against minimal versions of +those contracts. They cover valid handlers, shared and explicit response pairing, +boxed requests, independent wire helpers, and invalid declarations: + +```sh +cargo test -p truapi-macros --locked +``` diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index 649a97575..22e33e14b 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -1,149 +1,22 @@ -//! Proc-macros for TrUAPI trait annotations. +//! Proc macros for TrUAPI annotations, versioned envelopes, and inter-host SSO. //! -//! `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. -//! -//! At compile time the macro validates that every id literal is a `u8`. It emits -//! a hidden doc line so the value survives into rustdoc JSON, where -//! `truapi-codegen` reads it to build generated wire tables. -//! -//! Why doc-smuggling instead of leaving `#[wire(request_id = N)]` on the method for -//! the codegen to read directly: rustdoc's JSON `attrs` field stringifies -//! attributes, but rustc rejects unknown helper attributes on trait methods -//! unless they are declared via a tool prefix or consumed by an active -//! proc-macro. Re-emitting the marker as a `#[doc]` line lets the value reach -//! 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::{ - Attribute, Ident, ItemFn, ItemTrait, LitInt, Token, TraitItemFn, Type, Visibility, braced, - parse_macro_input, -}; - -#[derive(Default)] -struct WireArgs { - host_initiated: bool, - request_id: Option, - response_id: Option, - start_id: Option, - stop_id: Option, - interrupt_id: Option, - receive_id: Option, - sensitive: bool, -} +//! Each macro's implementation lives in its own module. Rust requires the +//! public proc-macro entry points to be defined at the crate root. -struct ServiceArgs { - required_execution: Ident, -} +mod service; +mod sso_service; +mod sso_wire; +mod versioned_type; +mod wire; -impl Parse for ServiceArgs { - fn parse(input: ParseStream<'_>) -> syn::Result { - let key: Ident = input.parse()?; - if key != "required_execution" { - return Err(syn::Error::new(key.span(), "expected `required_execution`")); - } - input.parse::()?; - let required_execution = input.parse()?; - if !input.is_empty() { - return Err(input.error("unexpected service attribute arguments")); - } - Ok(Self { required_execution }) - } -} +use proc_macro::TokenStream; /// Declare connection-scoped middleware required by a TrUAPI service trait. /// /// The metadata is preserved in rustdoc JSON for `truapi-codegen`. #[proc_macro_attribute] pub fn service(args: TokenStream, item: TokenStream) -> TokenStream { - let args = parse_macro_input!(args as ServiceArgs); - let mut item = parse_macro_input!(item as ItemTrait); - let tag = format!("@service_required_execution={}", args.required_execution); - item.attrs.push(syn::parse_quote!(#[doc = #tag])); - quote!(#item).into() -} - -impl Parse for WireArgs { - fn parse(input: ParseStream<'_>) -> syn::Result { - let mut args = WireArgs::default(); - - while !input.is_empty() { - let key: Ident = input.parse()?; - - if key == "host_initiated" { - if args.host_initiated { - return Err(syn::Error::new(key.span(), "duplicate `host_initiated`")); - } - args.host_initiated = true; - } else if key == "sensitive" { - // `sensitive` is a bare flag with no `= N` value: it classifies - // the method's payloads as carrying key material or bearer - // secrets. The classification is folded into the wire - // schema-hash fingerprint, so a change in a frame's sensitivity - // is caught as contract drift. It suppresses no decoding: it - // reaches neither the generated TS nor any runtime. - if args.sensitive { - return Err(syn::Error::new(key.span(), "duplicate `sensitive`")); - } - args.sensitive = true; - } else { - input.parse::()?; - let lit: LitInt = input.parse()?; - let value = lit.base10_parse().map_err(|err| { - syn::Error::new(lit.span(), format!("wire id must fit in a u8: {err}")) - })?; - - set_id(&mut args, &key, value)?; - } - - if input.is_empty() { - break; - } - input.parse::()?; - } - - if args.request_id.is_none() && args.start_id.is_none() { - return Err(input.error("missing `request_id = N` or `start_id = N`")); - } - - Ok(args) - } -} - -fn set_id(args: &mut WireArgs, key: &Ident, value: u8) -> syn::Result<()> { - let target = if key == "request_id" { - &mut args.request_id - } else if key == "response_id" { - &mut args.response_id - } else if key == "start_id" { - &mut args.start_id - } else if key == "stop_id" { - &mut args.stop_id - } else if key == "interrupt_id" { - &mut args.interrupt_id - } else if key == "receive_id" { - &mut args.receive_id - } else { - return Err(syn::Error::new( - key.span(), - "expected one of `request_id`, `response_id`, `start_id`, `stop_id`, `interrupt_id`, `receive_id`, `host_initiated`, `sensitive`", - )); - }; - - if target.replace(value).is_some() { - return Err(syn::Error::new(key.span(), format!("duplicate `{key}`"))); - } - - Ok(()) + service::expand(args, item) } /// Mark a TrUAPI trait method with its wire-protocol discriminant id. @@ -169,137 +42,7 @@ fn set_id(args: &mut WireArgs, key: &Ident, value: u8) -> syn::Result<()> { /// extracts from rustdoc JSON to build the wire table and versioned clients. #[proc_macro_attribute] pub fn wire(args: TokenStream, item: TokenStream) -> TokenStream { - let args = parse_macro_input!(args as WireArgs); - let tags = wire_tags(&args); - - if let Ok(mut method) = syn::parse::(item.clone()) { - for tag in tags { - method.attrs.push(syn::parse_quote!(#[doc = #tag])); - } - return quote!(#method).into(); - } - - if let Ok(mut function) = syn::parse::(item) { - for tag in tags { - function.attrs.push(syn::parse_quote!(#[doc = #tag])); - } - return quote!(#function).into(); - } - - syn::Error::new( - proc_macro2::Span::call_site(), - "#[wire] can only be applied to trait methods or free functions", - ) - .to_compile_error() - .into() -} - -fn wire_tags(args: &WireArgs) -> Vec { - let mut tags: Vec = [ - ("request_id", args.request_id), - ("response_id", args.response_id), - ("start_id", args.start_id), - ("stop_id", args.stop_id), - ("interrupt_id", args.interrupt_id), - ("receive_id", args.receive_id), - ] - .into_iter() - .filter_map(|(name, value)| value.map(|id| format!("@wire_{name}={id}"))) - .collect(); - if args.host_initiated { - tags.push("@wire_host_initiated".to_string()); - } - if args.sensitive { - tags.push("@wire_sensitive=true".to_string()); - } - tags -} - -/// 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 }) - } -} - -/// True when `attrs` already carries a doc comment or `#[doc]` attribute. -fn has_doc(attrs: &[Attribute]) -> bool { - attrs.iter().any(|attr| attr.path().is_ident("doc")) -} - -/// 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()) + wire::expand(args, item) } /// Generate versioned message envelopes. @@ -326,128 +69,35 @@ fn variant_version(ident: &Ident) -> syn::Result { /// 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(), - } + versioned_type::expand(item) } -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) +/// Classify the SSO wire enum's request, response, and disconnect variants. +/// +/// Emits crate-private `AnyRequest`, `Incoming`, `classify()`, request wrapping, +/// and the enum's `name()`, `responding_to()`, and `with_responding_to()` next to +/// the enum. Request/response pairing comes from `#[sso_service]`. Only valid +/// inside `truapi-server`. +#[proc_macro_derive(SsoWire)] +pub fn derive_sso_wire(item: TokenStream) -> TokenStream { + sso_wire::expand(item) } -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; - let default_doc = (!has_doc(vattrs)).then(|| { - let doc = match &variant.ty { - Some(_) => format!("Version {version} payload."), - None => format!("Version {version} (no payload)."), - }; - quote! { #[doc = #doc] } - }); - match &variant.ty { - Some(ty) => { - variant_defs.push( - quote! { #(#vattrs)* #default_doc #[codec(index = #index)] #vident(#ty) }, - ); - version_arms.push(quote! { Self::#vident(..) => #version_lit }); - } - None => { - variant_defs - .push(quote! { #(#vattrs)* #default_doc #[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) +/// Define SSO handlers in a dedicated inherent implementation. +/// +/// Every method must be `async fn name(&self, cx: &SsoRequestContext, request: +/// ) -> `, where the named response aliases its `Result` +/// payload and selects the wire variant. The method name selects the request +/// variant; its payload type can be generic. Each signature supplies `SsoRequest` pairing; +/// the macro generates an exhaustive `dispatch` method on the service type. +/// Handler return types expand to `SsoReply`, and bodies return +/// ordinary `Result` payloads or explicit replies with a transcript outcome. +/// An inner async block preserves `return` and `?` semantics. Constructors and +/// other helpers belong in a separate, unannotated implementation. +/// +/// The macro uses native async methods and refers to the context and reply +/// types in `crate::runtime::sso_service`. It only works inside `truapi-server`. +#[proc_macro_attribute] +pub fn sso_service(args: TokenStream, item: TokenStream) -> TokenStream { + sso_service::expand(args, item) } diff --git a/rust/crates/truapi-macros/src/service.rs b/rust/crates/truapi-macros/src/service.rs new file mode 100644 index 000000000..fcbf845b6 --- /dev/null +++ b/rust/crates/truapi-macros/src/service.rs @@ -0,0 +1,34 @@ +//! Connection-scoped middleware metadata for TrUAPI service traits. + +use proc_macro::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{Ident, ItemTrait, Token, parse_macro_input}; + +struct ServiceArgs { + required_execution: Ident, +} + +impl Parse for ServiceArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let key: Ident = input.parse()?; + if key != "required_execution" { + return Err(syn::Error::new(key.span(), "expected `required_execution`")); + } + input.parse::()?; + let required_execution = input.parse()?; + if !input.is_empty() { + return Err(input.error("unexpected service attribute arguments")); + } + Ok(Self { required_execution }) + } +} + +/// Parse the macro input and emit generated code or a compiler diagnostic. +pub(super) fn expand(args: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as ServiceArgs); + let mut item = parse_macro_input!(item as ItemTrait); + let tag = format!("@service_required_execution={}", args.required_execution); + item.attrs.push(syn::parse_quote!(#[doc = #tag])); + quote!(#item).into() +} diff --git a/rust/crates/truapi-macros/src/sso_service.rs b/rust/crates/truapi-macros/src/sso_service.rs new file mode 100644 index 000000000..afb42543a --- /dev/null +++ b/rust/crates/truapi-macros/src/sso_service.rs @@ -0,0 +1,237 @@ +//! Request/response pairing and dispatch for an inherent SSO handler implementation. + +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::ext::IdentExt; +use syn::{FnArg, ImplItem, ItemImpl, Signature, Type, parse_macro_input}; + +/// Parse the macro input and emit generated code or a compiler diagnostic. +pub(super) fn expand( + args: proc_macro::TokenStream, + item: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + if !args.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "`sso_service` takes no arguments", + ) + .to_compile_error() + .into(); + } + let item = parse_macro_input!(item as syn::Item); + let result = match item { + syn::Item::Impl(item) => expand_sso_service(item), + other => Err(syn::Error::new_spanned( + other, + "sso_service requires an inherent implementation", + )), + }; + match result { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +/// Pair and dispatch the handlers in one inherent implementation. +fn expand_sso_service(mut item: ItemImpl) -> syn::Result { + if item.trait_.is_some() { + return Err(syn::Error::new_spanned( + &item, + "sso_service requires an inherent implementation", + )); + } + if !item.generics.params.is_empty() || item.generics.where_clause.is_some() { + return Err(syn::Error::new_spanned( + &item.generics, + "SSO handlers require a concrete service type", + )); + } + let wire = quote!(crate::host_logic::sso::wire); + let message = quote!(crate::host_logic::sso::messages::v1::RemoteMessage); + let runtime = quote!(crate::runtime::sso_service); + let reply = quote!(#runtime::SsoReply); + let mut impls = Vec::new(); + let mut arms = Vec::new(); + for entry in &mut item.items { + let ImplItem::Fn(method) = entry else { + return Err(syn::Error::new_spanned( + entry, + "the annotated implementation holds only SSO handler methods", + )); + }; + let (request_ty, response_ty) = method_types(&method.sig)?; + method.sig.output = syn::parse_quote!(-> #reply<#response_ty>); + let body = &method.block; + method.block = syn::parse_quote!({ + (async move #body).await.into() + }); + let response_variant = last_segment(&response_ty)?; + let name = &method.sig.ident; + let method_name = name.unraw().to_string(); + let stem: String = method_name + .split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect(); + let variant = format_ident!("{stem}Request", span = name.span()); + impls.push(quote! { + impl #wire::SsoRequest for #request_ty { + const NAME: &'static str = #method_name; + type Response = #response_ty; + + fn response_into_message( + response: crate::host_logic::sso::messages::Response, + ) -> #message { + #message::#response_variant(response) + } + + fn response_from_message( + message: #message, + ) -> Option> { + match message { + #message::#response_variant(response) => Some(response), + _ => None, + } + } + + fn into_message(self) -> #message { + use crate::host_logic::sso::messages::v1::AnyRequest; + AnyRequest::#variant(self).into() + } + } + }); + arms.push(quote! { + AnyRequest::#variant(request) => { + let reply = match &cx { + Some(cx) => self.#name(cx, request).await, + None => Err(#wire::SsoError::not_connected()).into(), + }; + reply.finish(&message_id, <#request_ty as #wire::SsoRequest>::response_into_message) + } + }); + } + if arms.is_empty() { + return Err(syn::Error::new_spanned( + &item.self_ty, + "the annotated implementation declares no SSO handlers", + )); + } + + item.items.push(syn::parse_quote! { + /// Answer one wire message with this service. + /// + /// `session` is the signing host's current session; without one every + /// request is answered with its error type's `not_connected()`. + pub(crate) async fn dispatch( + &self, + session: Option, + message: crate::host_logic::sso::messages::RemoteMessage, + ) -> #runtime::Dispatch { + use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; + let crate::host_logic::sso::messages::RemoteMessageData::V1(data) = message.data; + let request = match classify(data) { + Incoming::Request(request) => request, + Incoming::Response(name) => return #runtime::Dispatch::NotARequest(name), + Incoming::Disconnected => return #runtime::Dispatch::Disconnected, + }; + let message_id = message.message_id; + let cx = session.map(|session| #runtime::SsoRequestContext::new(&message_id, session)); + let answer = match request { + #(#arms)* + }; + #runtime::Dispatch::Response(Box::new(answer)) + } + }); + Ok(quote! { + #item + #(#impls)* + }) +} + +fn method_types(sig: &Signature) -> syn::Result<(Type, Type)> { + let mut inputs = sig.inputs.iter(); + let shape_error = || { + syn::Error::new_spanned( + sig, + "expected `async fn name(&self, cx: &SsoRequestContext, request: ) -> `", + ) + }; + let Some(FnArg::Receiver(receiver)) = inputs.next() else { + return Err(shape_error()); + }; + if receiver.reference.is_none() + || receiver.mutability.is_some() + || receiver.colon_token.is_some() + { + return Err(shape_error()); + } + let Some(FnArg::Typed(context)) = inputs.next() else { + return Err(shape_error()); + }; + let Type::Reference(context_ty) = context.ty.as_ref() else { + return Err(shape_error()); + }; + if last_segment(&context_ty.elem)? != "SsoRequestContext" { + return Err(shape_error()); + } + let Some(FnArg::Typed(request)) = inputs.next() else { + return Err(shape_error()); + }; + if inputs.next().is_some() + || sig.asyncness.is_none() + || !sig.generics.params.is_empty() + || sig.generics.where_clause.is_some() + { + return Err(shape_error()); + } + let syn::ReturnType::Type(_, output) = &sig.output else { + return Err(shape_error()); + }; + let Type::Path(path) = output.as_ref() else { + return Err(shape_error()); + }; + let Some(segment) = path.path.segments.last() else { + return Err(shape_error()); + }; + if !segment.ident.to_string().ends_with("Response") { + return Err(shape_error()); + } + Ok(((*request.ty).clone(), output.as_ref().clone())) +} + +fn last_segment(ty: &Type) -> syn::Result { + match ty { + Type::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident.clone()) + .ok_or_else(|| syn::Error::new_spanned(ty, "expected a request type")), + _ => Err(syn::Error::new_spanned(ty, "expected a request type path")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_requires_an_explicit_wire_response() { + let item = syn::parse_quote! { + impl Service { + async fn sign(&self, cx: &SsoRequestContext, request: SignRequest) + -> Result, String> { Ok(vec![]) } + } + }; + let error = expand_sso_service(item).unwrap_err(); + assert_eq!( + error.to_string(), + "expected `async fn name(&self, cx: &SsoRequestContext, request: ) -> `" + ); + } +} diff --git a/rust/crates/truapi-macros/src/sso_wire.rs b/rust/crates/truapi-macros/src/sso_wire.rs new file mode 100644 index 000000000..11c223210 --- /dev/null +++ b/rust/crates/truapi-macros/src/sso_wire.rs @@ -0,0 +1,246 @@ +//! Classification, request wrapping, and correlation helpers for the SSO wire enum. + +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::{ + Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, Variant, parse_macro_input, +}; + +const DISCONNECT_VARIANT: &str = "Disconnected"; + +/// Parse the macro input and emit generated code or a compiler diagnostic. +pub(super) fn expand(item: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = parse_macro_input!(item as syn::DeriveInput); + match derive_sso_wire(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +/// Expand `#[derive(SsoWire)]`. +fn derive_sso_wire(input: DeriveInput) -> syn::Result { + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "SsoWire is derived on the wire enum", + )); + }; + let enum_ident = &input.ident; + let mut requests = Vec::new(); + let mut responses = Vec::new(); + let mut saw_disconnect = false; + for variant in &data.variants { + let name = variant.ident.to_string(); + if name == DISCONNECT_VARIANT { + if !matches!(variant.fields, Fields::Unit) { + return Err(syn::Error::new_spanned( + variant, + "`Disconnected` carries no payload", + )); + } + saw_disconnect = true; + } else if name.ends_with("Request") { + requests.push(RequestVariant::parse(variant)?); + } else if name.ends_with("Response") { + single_payload(variant)?; + responses.push(variant.ident.clone()); + } else { + return Err(syn::Error::new_spanned( + &variant.ident, + "variant must end in `Request` or `Response`, or be `Disconnected`", + )); + } + } + if !saw_disconnect { + return Err(syn::Error::new_spanned( + enum_ident, + "missing `Disconnected` variant", + )); + } + + let disconnect = format_ident!("{DISCONNECT_VARIANT}"); + let mut any_variants = Vec::new(); + let mut classify_arms = Vec::new(); + let mut wrap_arms = Vec::new(); + for request in &requests { + let variant = &request.variant; + let payload = &request.payload; + let (wrap, unwrap) = if request.boxed { + (quote!(Box::new(payload)), quote!(*payload)) + } else { + (quote!(payload), quote!(payload)) + }; + wrap_arms.push(quote! { + AnyRequest::#variant(payload) => #enum_ident::#variant(#wrap) + }); + let doc = format!("Payload of [`{enum_ident}::{variant}`]."); + any_variants.push(quote! { #[doc = #doc] #variant(#payload) }); + classify_arms.push(quote! { + #enum_ident::#variant(payload) => Incoming::Request(AnyRequest::#variant(#unwrap)) + }); + } + let mut retarget_arms = Vec::new(); + let mut name_arms = vec![quote! { #enum_ident::#disconnect => #DISCONNECT_VARIANT }]; + let mut responding_to_arms = Vec::new(); + for request in &requests { + let variant = &request.variant; + let variant_name = variant.to_string(); + let stem = variant_name + .strip_suffix("Request") + .expect("request variant"); + let name = snake_case(stem); + name_arms.push(quote! { #enum_ident::#variant(_) => #name }); + } + for variant in &responses { + let name = variant.to_string(); + classify_arms.push(quote! { #enum_ident::#variant(_) => Incoming::Response(#name) }); + name_arms.push(quote! { #enum_ident::#variant(_) => #name }); + responding_to_arms.push(quote! { + #enum_ident::#variant(response) => Some(&response.responding_to) + }); + retarget_arms.push(quote! { + #enum_ident::#variant(mut response) => { + response.responding_to = responding_to; + #enum_ident::#variant(response) + } + }); + } + Ok(quote! { + /// Every request payload the wire can carry, unwrapped from its variant. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) enum AnyRequest { + #(#any_variants,)* + } + + impl From for #enum_ident { + fn from(request: AnyRequest) -> Self { + match request { + #(#wrap_arms,)* + } + } + } + + /// Role of one decoded wire message. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) enum Incoming { + /// A request to dispatch. + Request(AnyRequest), + /// A response variant, named; requests never arrive as responses. + Response(&'static str), + /// The peer ended the session. + Disconnected, + } + + /// Sort a wire message into request, response, or disconnect. + pub(crate) fn classify(message: #enum_ident) -> Incoming { + match message { + #enum_ident::#disconnect => Incoming::Disconnected, + #(#classify_arms,)* + } + } + + impl #enum_ident { + /// Service method name for requests; variant name for other messages. + pub(crate) fn name(&self) -> &'static str { + match self { + #(#name_arms,)* + } + } + + /// `message_id` of the request a response answers; `None` for + /// requests and `Disconnected`. + pub(crate) fn responding_to(&self) -> Option<&str> { + match self { + #(#responding_to_arms,)* + _ => None, + } + } + + /// Re-address a response to the request sent as `responding_to`; + /// requests and `Disconnected` pass through unchanged. + pub(crate) fn with_responding_to(self, responding_to: String) -> Self { + match self { + #(#retarget_arms,)* + other => other, + } + } + } + }) +} + +struct RequestVariant { + variant: Ident, + payload: Type, + boxed: bool, +} + +impl RequestVariant { + fn parse(variant: &Variant) -> syn::Result { + let payload = single_payload(variant)?; + let (payload, boxed) = match box_inner(payload) { + Some(inner) => (inner.clone(), true), + None => (payload.clone(), false), + }; + Ok(Self { + variant: variant.ident.clone(), + payload, + boxed, + }) + } +} + +fn single_payload(variant: &Variant) -> syn::Result<&Type> { + match &variant.fields { + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => Ok(&fields.unnamed[0].ty), + _ => Err(syn::Error::new_spanned( + variant, + "expected exactly one tuple payload", + )), + } +} + +fn box_inner(ty: &Type) -> Option<&Type> { + let Type::Path(path) = ty else { return None }; + let segment = path.path.segments.last()?; + if segment.ident != "Box" { + return None; + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + match args.args.first()? { + GenericArgument::Type(inner) if args.args.len() == 1 => Some(inner), + _ => None, + } +} + +/// Convert a request variant stem to its handler and client action name. +fn snake_case(name: &str) -> String { + let mut out = String::with_capacity(name.len() + 4); + for (index, ch) in name.chars().enumerate() { + if ch.is_ascii_uppercase() { + if index > 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stems_become_method_names() { + assert_eq!(snake_case("GetAccountAlias"), "get_account_alias"); + assert_eq!(snake_case("Sign"), "sign"); + assert_eq!( + snake_case("CreateTransactionWithLegacyAccount"), + "create_transaction_with_legacy_account" + ); + } +} diff --git a/rust/crates/truapi-macros/src/versioned_type.rs b/rust/crates/truapi-macros/src/versioned_type.rs new file mode 100644 index 000000000..3859d36bd --- /dev/null +++ b/rust/crates/truapi-macros/src/versioned_type.rs @@ -0,0 +1,222 @@ +//! Versioned message envelopes and their conversion trait implementations. + +use proc_macro::TokenStream; +use proc_macro2::Literal; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{Attribute, Ident, Token, Type, Visibility, braced, parse_macro_input}; + +/// 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 }) + } +} + +/// True when `attrs` already carries a doc comment or `#[doc]` attribute. +fn has_doc(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| attr.path().is_ident("doc")) +} + +/// 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()) +} + +/// Parse the macro input and emit generated code or a compiler diagnostic. +pub(super) fn expand(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; + let default_doc = (!has_doc(vattrs)).then(|| { + let doc = match &variant.ty { + Some(_) => format!("Version {version} payload."), + None => format!("Version {version} (no payload)."), + }; + quote! { #[doc = #doc] } + }); + match &variant.ty { + Some(ty) => { + variant_defs.push( + quote! { #(#vattrs)* #default_doc #[codec(index = #index)] #vident(#ty) }, + ); + version_arms.push(quote! { Self::#vident(..) => #version_lit }); + } + None => { + variant_defs + .push(quote! { #(#vattrs)* #default_doc #[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-macros/src/wire.rs b/rust/crates/truapi-macros/src/wire.rs new file mode 100644 index 000000000..da7e9efd8 --- /dev/null +++ b/rust/crates/truapi-macros/src/wire.rs @@ -0,0 +1,144 @@ +//! Wire-protocol metadata for TrUAPI methods. +//! +//! IDs and flags are emitted as hidden doc tags so they survive into rustdoc +//! JSON for `truapi-codegen`. Rust rejects unknown helper attributes on methods; +//! doc tags preserve the metadata without requiring such attributes. + +use proc_macro::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{Ident, ItemFn, LitInt, Token, TraitItemFn, parse_macro_input}; + +#[derive(Default)] +struct WireArgs { + host_initiated: bool, + request_id: Option, + response_id: Option, + start_id: Option, + stop_id: Option, + interrupt_id: Option, + receive_id: Option, + sensitive: bool, +} + +impl Parse for WireArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut args = WireArgs::default(); + + while !input.is_empty() { + let key: Ident = input.parse()?; + + if key == "host_initiated" { + if args.host_initiated { + return Err(syn::Error::new(key.span(), "duplicate `host_initiated`")); + } + args.host_initiated = true; + } else if key == "sensitive" { + // `sensitive` is a bare flag with no `= N` value: it classifies + // the method's payloads as carrying key material or bearer + // secrets. The classification is folded into the wire + // schema-hash fingerprint, so a change in a frame's sensitivity + // is caught as contract drift. It suppresses no decoding: it + // reaches neither the generated TS nor any runtime. + if args.sensitive { + return Err(syn::Error::new(key.span(), "duplicate `sensitive`")); + } + args.sensitive = true; + } else { + input.parse::()?; + let lit: LitInt = input.parse()?; + let value = lit.base10_parse().map_err(|err| { + syn::Error::new(lit.span(), format!("wire id must fit in a u8: {err}")) + })?; + + set_id(&mut args, &key, value)?; + } + + if input.is_empty() { + break; + } + input.parse::()?; + } + + if args.request_id.is_none() && args.start_id.is_none() { + return Err(input.error("missing `request_id = N` or `start_id = N`")); + } + + Ok(args) + } +} + +fn set_id(args: &mut WireArgs, key: &Ident, value: u8) -> syn::Result<()> { + let target = if key == "request_id" { + &mut args.request_id + } else if key == "response_id" { + &mut args.response_id + } else if key == "start_id" { + &mut args.start_id + } else if key == "stop_id" { + &mut args.stop_id + } else if key == "interrupt_id" { + &mut args.interrupt_id + } else if key == "receive_id" { + &mut args.receive_id + } else { + return Err(syn::Error::new( + key.span(), + "expected one of `request_id`, `response_id`, `start_id`, `stop_id`, `interrupt_id`, `receive_id`, `host_initiated`, `sensitive`", + )); + }; + + if target.replace(value).is_some() { + return Err(syn::Error::new(key.span(), format!("duplicate `{key}`"))); + } + + Ok(()) +} + +/// Parse the macro input and emit generated code or a compiler diagnostic. +pub(super) fn expand(args: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as WireArgs); + let tags = wire_tags(&args); + + if let Ok(mut method) = syn::parse::(item.clone()) { + for tag in tags { + method.attrs.push(syn::parse_quote!(#[doc = #tag])); + } + return quote!(#method).into(); + } + + if let Ok(mut function) = syn::parse::(item) { + for tag in tags { + function.attrs.push(syn::parse_quote!(#[doc = #tag])); + } + return quote!(#function).into(); + } + + syn::Error::new( + proc_macro2::Span::call_site(), + "#[wire] can only be applied to trait methods or free functions", + ) + .to_compile_error() + .into() +} + +fn wire_tags(args: &WireArgs) -> Vec { + let mut tags: Vec = [ + ("request_id", args.request_id), + ("response_id", args.response_id), + ("start_id", args.start_id), + ("stop_id", args.stop_id), + ("interrupt_id", args.interrupt_id), + ("receive_id", args.receive_id), + ] + .into_iter() + .filter_map(|(name, value)| value.map(|id| format!("@wire_{name}={id}"))) + .collect(); + if args.host_initiated { + tags.push("@wire_host_initiated".to_string()); + } + if args.sensitive { + tags.push("@wire_sensitive=true".to_string()); + } + tags +} diff --git a/rust/crates/truapi-macros/tests/sso.rs b/rust/crates/truapi-macros/tests/sso.rs new file mode 100644 index 000000000..d87a16e4d --- /dev/null +++ b/rust/crates/truapi-macros/tests/sso.rs @@ -0,0 +1,8 @@ +//! Compiler contracts for the SSO derives and handler attribute. + +#[test] +fn sso_handler_contracts() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/sso/pass/*.rs"); + cases.compile_fail("tests/ui/sso/fail/*.rs"); +} diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/helper_in_handler_block.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/helper_in_handler_block.rs new file mode 100644 index 000000000..15dc27dd8 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/helper_in_handler_block.rs @@ -0,0 +1,10 @@ +struct Service; + +#[truapi_macros::sso_service] +impl Service { + fn new() -> Self { + Self + } +} + +fn main() {} diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/helper_in_handler_block.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/helper_in_handler_block.stderr new file mode 100644 index 000000000..34721e58a --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/helper_in_handler_block.stderr @@ -0,0 +1,5 @@ +error: expected `async fn name(&self, cx: &SsoRequestContext, request: ) -> ` + --> tests/ui/sso/fail/helper_in_handler_block.rs:5:5 + | +5 | fn new() -> Self { + | ^^^^^^^^^^^^^^^^ diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.rs new file mode 100644 index 000000000..dc5ac2069 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.rs @@ -0,0 +1,16 @@ +include!("../support/wire.rs"); +include!("../support/runtime.rs"); + +use host_logic::sso::messages::{FooResponse, Request}; +use runtime::sso_service::SsoRequestContext; + +struct Service; + +#[truapi_macros::sso_service] +impl Service { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> FooResponse { + Ok(1) + } +} + +fn main() {} diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.stderr new file mode 100644 index 000000000..e2c0a0ca7 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.stderr @@ -0,0 +1,21 @@ +error[E0004]: non-exhaustive patterns: `AnyRequest::BarRequest(_)` not covered + --> tests/ui/sso/fail/missing_handler.rs:9:1 + | + 9 | #[truapi_macros::sso_service] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ pattern `AnyRequest::BarRequest(_)` not covered + | +note: `AnyRequest` defined here + --> tests/ui/sso/fail/../support/wire.rs + | + | #[derive(truapi_macros::SsoWire)] + | ^^^^^^^^^^^^^^^^^^^^^^ +... + | BarRequest(BarRequest), + | ---------- not covered + = note: the matched value is of type `AnyRequest` + = note: this error originates in the attribute macro `truapi_macros::sso_service` which comes from the expansion of the derive macro `truapi_macros::SsoWire` (in Nightly builds, run with -Z macro-backtrace for more info) +help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown + | + 9 ~ #[truapi_macros::sso_service], +10 + AnyRequest::BarRequest(_) => todo!() + | diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.rs new file mode 100644 index 000000000..8bc7eaa22 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.rs @@ -0,0 +1,25 @@ +include!("../support/wire.rs"); +include!("../support/runtime.rs"); + +use host_logic::sso::messages::*; +use runtime::sso_service::SsoRequestContext; + +struct Service; +struct BazRequest; + +#[truapi_macros::sso_service] +impl Service { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> FooResponse { + Ok(1) + } + + async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> BarResponse { + Ok(2) + } + + async fn baz(&self, _: &SsoRequestContext, _request: BazRequest) -> FooResponse { + Ok(3) + } +} + +fn main() {} diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.stderr new file mode 100644 index 000000000..7e10db4db --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.stderr @@ -0,0 +1,16 @@ +error[E0599]: no variant, associated function, or constant named `BazRequest` found for enum `AnyRequest` in the current scope + --> tests/ui/sso/fail/request_without_variant.rs:20:14 + | +20 | async fn baz(&self, _: &SsoRequestContext, _request: BazRequest) -> FooResponse { + | ^^^ variant, associated function, or constant not found in `AnyRequest` + | + ::: tests/ui/sso/fail/../support/wire.rs + | + | #[derive(truapi_macros::SsoWire)] + | ---------------------- variant, associated function, or constant `BazRequest` not found for this enum + | +help: there is a variant with a similar name + | +20 - async fn baz(&self, _: &SsoRequestContext, _request: BazRequest) -> FooResponse { +20 + async fn BarRequest(&self, _: &SsoRequestContext, _request: BazRequest) -> FooResponse { + | diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.rs new file mode 100644 index 000000000..0816d9ea1 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.rs @@ -0,0 +1,21 @@ +include!("../support/wire.rs"); +include!("../support/runtime.rs"); + +use host_logic::sso::messages::*; +use runtime::sso_service::SsoRequestContext; + +type MissingResponse = FooResponse; +struct Service; + +#[truapi_macros::sso_service] +impl Service { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> MissingResponse { + Ok(1) + } + + async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> BarResponse { + Ok(2) + } +} + +fn main() {} diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.stderr new file mode 100644 index 000000000..a8914aa6d --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.stderr @@ -0,0 +1,10 @@ +error[E0599]: no variant, associated function, or constant named `MissingResponse` found for enum `messages::v1::RemoteMessage` in the current scope + --> tests/ui/sso/fail/response_without_variant.rs:12:75 + | +12 | async fn foo(&self, _: &SsoRequestContext, _request: Request) -> MissingResponse { + | ^^^^^^^^^^^^^^^ variant, associated function, or constant not found in `messages::v1::RemoteMessage` + | + ::: tests/ui/sso/fail/../support/wire.rs + | + | pub enum RemoteMessage { + | ---------------------- variant, associated function, or constant `MissingResponse` not found for this enum diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/trait.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/trait.rs new file mode 100644 index 000000000..008785020 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/trait.rs @@ -0,0 +1,4 @@ +#[truapi_macros::sso_service] +trait Service {} + +fn main() {} diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/trait.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/trait.stderr new file mode 100644 index 000000000..0903be205 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/trait.stderr @@ -0,0 +1,5 @@ +error: sso_service requires an inherent implementation + --> tests/ui/sso/fail/trait.rs:2:1 + | +2 | trait Service {} + | ^^^^^^^^^^^^^^^^ diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.rs new file mode 100644 index 000000000..2b620778c --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.rs @@ -0,0 +1,20 @@ +include!("../support/wire.rs"); +include!("../support/runtime.rs"); + +use host_logic::sso::messages::*; +use runtime::sso_service::SsoRequestContext; + +struct Service; + +#[truapi_macros::sso_service] +impl Service { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> FooResponse { + Ok("wrong payload type") + } + + async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> BarResponse { + Ok(2) + } +} + +fn main() {} diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.stderr new file mode 100644 index 000000000..2d278f075 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.stderr @@ -0,0 +1,15 @@ +error[E0277]: the trait bound `SsoReply>: From>` is not satisfied + --> tests/ui/sso/fail/wrong_payload.rs:9:1 + | +9 | #[truapi_macros::sso_service] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `From>` is not implemented for `SsoReply>` + but trait `From>` is implemented for it + --> tests/ui/sso/fail/../support/runtime.rs + | + | impl

From

for SsoReply

{ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: for that trait implementation, expected `u32`, found `&str` + = note: required for `Result<&str, _>` to implement `Into>>` + = note: this error originates in the attribute macro `truapi_macros::sso_service` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs new file mode 100644 index 000000000..35368002f --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs @@ -0,0 +1,55 @@ +include!("../support/wire.rs"); +include!("../support/runtime.rs"); + +use host_logic::sso::{ + messages::*, + wire::{ResponseOutcome, SsoRequest}, +}; +use runtime::sso_service::{SsoReply, SsoRequestContext}; + +struct Service; + +impl Service { + fn new() -> Self { + Self + } + + async fn value(&self, value: u32) -> Result { + Ok(value) + } +} + +#[truapi_macros::sso_service] +impl Service { + async fn r#foo(&self, _: &SsoRequestContext, Request(value): Request) -> BarResponse { + let value = self.value(value).await?; + if value == 0 { + return Err("zero".into()); + } + Ok(value) + } + + async fn bar(&self, _: &SsoRequestContext, _: BarRequest) -> FooResponse { + SsoReply::::from(Ok(2)).with_outcome(ResponseOutcome) + } +} + +fn main() { + // The return type selects the variant, even when its name differs from the handler's. + let response = Request::::response_into_message(Response { + responding_to: "m-1".into(), + payload: Ok(7), + }); + assert!(matches!(response, v1::RemoteMessage::BarResponse(_))); + assert!(BarRequest::response_from_message(response).is_none()); + + fn require_send(_: impl core::future::Future + Send) {} + let service = Service::new(); + require_send(service.dispatch( + None, + RemoteMessage { + message_id: "m-1".into(), + data: RemoteMessageData::V1(v1::RemoteMessage::FooRequest(Box::new(Request(1)))), + }, + )); +} diff --git a/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs new file mode 100644 index 000000000..2673d02f0 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs @@ -0,0 +1,27 @@ +include!("../support/wire.rs"); +include!("../support/runtime.rs"); + +use host_logic::sso::{messages::*, wire::SsoRequest}; +use runtime::sso_service::SsoRequestContext; + +struct Service; + +#[truapi_macros::sso_service] +impl Service { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> FooResponse { + Ok(1) + } + + async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> FooResponse { + Ok(2) + } +} + +fn main() { + let response = BarRequest::response_into_message(Response { + responding_to: "m-1".into(), + payload: Ok(7), + }); + assert!(matches!(response, v1::RemoteMessage::FooResponse(_))); + assert!(Request::::response_from_message(response).is_some()); +} diff --git a/rust/crates/truapi-macros/tests/ui/sso/pass/wire_without_service.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/wire_without_service.rs new file mode 100644 index 000000000..4c242868c --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/wire_without_service.rs @@ -0,0 +1,26 @@ +include!("../support/wire.rs"); + +fn main() { + use host_logic::sso::messages::{BarRequest, Request, Response, v1}; + use v1::{AnyRequest, Incoming, classify}; + + for (request, name) in [ + (AnyRequest::FooRequest(Request(1)), "foo"), + (AnyRequest::BarRequest(BarRequest), "bar"), + ] { + let message: v1::RemoteMessage = request.clone().into(); + assert_eq!(message.name(), name); + assert_eq!(classify(message), Incoming::Request(request)); + } + assert_eq!( + classify(v1::RemoteMessage::FooResponse(Response { + responding_to: "m-1".into(), + payload: Ok(7), + })), + Incoming::Response("FooResponse") + ); + assert_eq!( + classify(v1::RemoteMessage::Disconnected), + Incoming::Disconnected + ); +} diff --git a/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs b/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs new file mode 100644 index 000000000..ea02237f2 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs @@ -0,0 +1,52 @@ +// Compile-time contracts consumed by generated dispatch; runtime behavior is tested in the server. +#[allow(dead_code)] +mod runtime { + pub mod authority { + pub struct AuthoritySession; + } + + pub mod sso_service { + use crate::host_logic::sso::messages::{Response, v1}; + use crate::host_logic::sso::wire::ResponseOutcome; + + pub struct SsoRequestContext; + + impl SsoRequestContext { + pub fn new(_: &str, _: super::authority::AuthoritySession) -> Self { + Self + } + } + + pub enum Dispatch { + Response(Box), + Disconnected, + NotARequest(&'static str), + } + + pub struct Answer; + + pub struct SsoReply

(P); + + impl

From

for SsoReply

{ + fn from(payload: P) -> Self { + Self(payload) + } + } + + impl

SsoReply

{ + pub fn with_outcome(self, _: ResponseOutcome) -> Self { + self + } + } + + impl SsoReply> { + pub fn finish( + self, + _: &str, + _: impl FnOnce(Response>) -> v1::RemoteMessage, + ) -> Answer { + Answer + } + } + } +} diff --git a/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs b/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs new file mode 100644 index 000000000..4d7f522f1 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs @@ -0,0 +1,69 @@ +// Minimal server wire contract for testing macro expansions independently of runtime I/O. +#[allow(dead_code)] +mod host_logic { + pub mod sso { + pub mod wire { + use super::messages::{Response, v1::RemoteMessage}; + + pub trait SsoRequest: Sized { + const NAME: &'static str; + type Response; + fn into_message(self) -> RemoteMessage; + fn response_into_message(response: Response) -> RemoteMessage; + fn response_from_message( + message: RemoteMessage, + ) -> Option>; + } + + pub trait SsoError: core::fmt::Display { + fn not_connected() -> Self; + } + + impl SsoError for String { + fn not_connected() -> Self { + "disconnected".into() + } + } + + pub struct ResponseOutcome; + } + + pub mod messages { + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct Request(pub T); + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct BarRequest; + + pub struct Response

{ + pub responding_to: String, + pub payload: P, + } + + pub type FooResponse = Result; + pub type BarResponse = Result; + + pub struct RemoteMessage { + pub message_id: String, + pub data: RemoteMessageData, + } + + pub enum RemoteMessageData { + V1(v1::RemoteMessage), + } + + pub mod v1 { + use super::*; + + #[derive(truapi_macros::SsoWire)] + pub enum RemoteMessage { + Disconnected, + FooRequest(Box>), + FooResponse(Response), + BarRequest(BarRequest), + BarResponse(Response), + } + } + } + } +}