From 8fa487a52d5da097bd4cfcecc4c40b6554cfa2ef Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 8 Sep 2026 20:52:43 +0000 Subject: [PATCH 1/8] feat(sso): add handler and wire contract macros --- CLAUDE.md | 3 +- Cargo.lock | 31 + README.md | 4 +- rust/crates/truapi-macros/Cargo.toml | 3 + rust/crates/truapi-macros/README.md | 63 ++ rust/crates/truapi-macros/src/lib.rs | 72 ++ rust/crates/truapi-macros/src/sso.rs | 619 ++++++++++++++++++ rust/crates/truapi-macros/tests/sso.rs | 8 + .../ui/sso/fail/helper_in_handler_block.rs | 10 + .../sso/fail/helper_in_handler_block.stderr | 5 + .../tests/ui/sso/fail/missing_handler.rs | 16 + .../tests/ui/sso/fail/missing_handler.stderr | 21 + .../ui/sso/fail/request_without_variant.rs | 25 + .../sso/fail/request_without_variant.stderr | 16 + .../truapi-macros/tests/ui/sso/fail/trait.rs | 4 + .../tests/ui/sso/fail/trait.stderr | 5 + .../tests/ui/sso/fail/wrong_payload.rs | 20 + .../tests/ui/sso/fail/wrong_payload.stderr | 13 + .../tests/ui/sso/fail/wrong_reply.rs | 20 + .../tests/ui/sso/fail/wrong_reply.stderr | 11 + .../tests/ui/sso/pass/explicit_pairing.rs | 23 + .../tests/ui/sso/pass/handlers.rs | 44 ++ .../tests/ui/sso/pass/shared_response.rs | 24 + .../tests/ui/sso/pass/wire_without_service.rs | 8 + .../tests/ui/sso/support/runtime.rs | 55 ++ .../tests/ui/sso/support/wire.rs | 89 +++ 26 files changed, 1210 insertions(+), 2 deletions(-) create mode 100644 rust/crates/truapi-macros/README.md create mode 100644 rust/crates/truapi-macros/src/sso.rs create mode 100644 rust/crates/truapi-macros/tests/sso.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/helper_in_handler_block.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/helper_in_handler_block.stderr create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.stderr create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.stderr create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/trait.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/trait.stderr create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.stderr create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.stderr create mode 100644 rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/pass/wire_without_service.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs create mode 100644 rust/crates/truapi-macros/tests/ui/sso/support/wire.rs diff --git a/CLAUDE.md b/CLAUDE.md index 876792efe..c6599c4ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,8 @@ 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/SsoResponse derives and + #[sso_service] for truapi-server's inter-host SSO protocol 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..4c7b7754e 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 the SSO handler and wire contracts. + 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..c74694d0b --- /dev/null +++ b/rust/crates/truapi-macros/README.md @@ -0,0 +1,63 @@ +# TrUAPI proc macros + +This crate provides TrUAPI wire annotations and versioned envelopes, plus +server-specific macros for inter-host SSO contracts. + +| Macro | Input | Generated code | +| --- | --- | --- | +| `SsoWire` | Hand-written `v1::RemoteMessage` enum | Request classification and wrapping, message names, and correlation helpers | +| `SsoResponse` | Response struct with `responding_to: String` followed by one `Result` field | Payload types and accessors, response construction, wire wrapping, and transcript outcome | +| `sso_service` | Dedicated inherent impl of SSO handlers | Request/response pairing, exhaustive dispatch, and handler reply conversion | + +## Handler contract + +Every method in the annotated impl is an endpoint. Its parameter names a wire +request type and its return type names the corresponding wire response: + +```rust +#[truapi_macros::sso_service] +impl SigningHostSsoService { + async fn get_account_alias( + &self, + cx: &SsoRequestContext, + request: GetAccountAliasRequest, + ) -> GetAccountAliasResponse { + self.signing_host + .account_alias(&cx.call, &cx.session, request) + .await + } +} +``` + +The method name is the request type's snake-case stem: `GetAccountAliasRequest` +requires `get_account_alias`. The named response defines the pairing, including +when two handlers share one response type. Constructors and internal helpers +belong in a separate, unannotated impl. + +Handler signatures expand to native async methods returning `SsoReply`. +Bodies return the response's ordinary `Result` payload or an explicit `SsoReply` +with a local transcript outcome. An inner async block preserves `?` and early +returns; `.into()` performs the reply conversion. + +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 the transcript outcome. Missing +handlers, undeclared wire variants, and incompatible payloads fail compilation. + +## 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..122dc8f4f 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -1,5 +1,11 @@ //! Proc-macros for TrUAPI trait annotations. //! +//! `SsoWire`, `SsoResponse`, and `sso_service` describe `truapi-server`'s +//! inter-host SSO protocol. The derives expose wire classification and response +//! payloads; the service attribute pairs requests and responses from method +//! signatures in an inherent implementation and generates dispatch. They are +//! documented in [`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`. @@ -20,6 +26,8 @@ //! proc-macro. Re-emitting the marker as a `#[doc]` line lets the value reach //! rustdoc through the only attribute that is always preserved verbatim. +mod sso; + use proc_macro::TokenStream; use proc_macro2::Literal; use quote::quote; @@ -451,3 +459,67 @@ fn expand_versioned_enum(def: &VersionedEnum) -> syn::Result TokenStream { + let input = parse_macro_input!(item as syn::DeriveInput); + match sso::derive_sso_wire(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +/// Implement `SsoResponse` for a response struct made of `responding_to` and +/// one `Result` payload field. `#[sso(outcome = path)]` swaps the +/// transcript classification for a bespoke function. Only valid inside +/// `truapi-server`. +#[proc_macro_derive(SsoResponse, attributes(sso))] +pub fn derive_sso_response(item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as syn::DeriveInput); + match sso::derive_sso_response(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +/// Define SSO handlers in a dedicated inherent implementation. +/// +/// Every method must be `async fn name(&self, cx: &SsoRequestContext, request: +/// ) -> `. 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 { + 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) => sso::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(), + } +} diff --git a/rust/crates/truapi-macros/src/sso.rs b/rust/crates/truapi-macros/src/sso.rs new file mode 100644 index 000000000..38d649eeb --- /dev/null +++ b/rust/crates/truapi-macros/src/sso.rs @@ -0,0 +1,619 @@ +//! Derives for the inter-host SSO protocol in `truapi-server`. +//! +//! `SsoWire` reads the hand-written `v1::RemoteMessage` enum and classifies +//! its variants. `sso_service` pairs requests with the wire responses named +//! in the handler signatures. `SsoResponse` reads a response's payload field. +//! These macros emit `crate::host_logic::sso::...` paths and only work inside +//! `truapi-server`. + +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::{ + Data, DeriveInput, Fields, FnArg, GenericArgument, ImplItem, ItemImpl, Pat, PathArguments, + Signature, Type, Variant, +}; + +const DISCONNECT_VARIANT: &str = "Disconnected"; + +fn wire_path() -> TokenStream { + quote!(crate::host_logic::sso::wire) +} + +fn enum_path() -> TokenStream { + quote!(crate::host_logic::sso::messages::v1::RemoteMessage) +} + +fn reply_path() -> TokenStream { + quote!(crate::runtime::sso_service::SsoReply) +} + +/// Expand `#[derive(SsoWire)]`. +pub(crate) 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") { + responses.push(ResponseVariant::parse(variant)?); + } 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 wire = wire_path(); + 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 response in &responses { + let variant = &response.variant; + let payload = &response.payload; + 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(#wire::SsoResponse::responding_to(response)) + }); + retarget_arms.push(quote! { + #enum_ident::#variant(response) => #enum_ident::#variant( + <#payload as #wire::SsoResponse>::new( + responding_to, + #wire::SsoResponse::into_payload(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, + }) + } +} + +struct ResponseVariant { + variant: Ident, + payload: Type, +} + +impl ResponseVariant { + fn parse(variant: &Variant) -> syn::Result { + Ok(Self { + variant: variant.ident.clone(), + payload: single_payload(variant)?.clone(), + }) + } +} + +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, + } +} + +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 +} + +/// Expand `#[derive(SsoResponse)]`. +pub(crate) fn derive_sso_response(input: DeriveInput) -> syn::Result { + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "SsoResponse is derived on a response struct", + )); + }; + let Fields::Named(fields) = &data.fields else { + return Err(syn::Error::new_spanned( + &input.ident, + "expected named fields", + )); + }; + let mut payload = None; + let mut saw_responding_to = false; + for field in &fields.named { + let ident = field.ident.as_ref().expect("named field"); + if ident == "responding_to" { + saw_responding_to = true; + } else if payload.replace((ident.clone(), &field.ty)).is_some() { + return Err(syn::Error::new_spanned( + ident, + "a response has `responding_to` and exactly one payload field", + )); + } + } + if !saw_responding_to { + return Err(syn::Error::new_spanned( + &input.ident, + "missing `responding_to: String`", + )); + } + let first_is_responding_to = fields + .named + .first() + .and_then(|field| field.ident.as_ref()) + .is_some_and(|ident| ident == "responding_to"); + if !first_is_responding_to { + return Err(syn::Error::new_spanned( + &input.ident, + "`responding_to` must be the first field: SCALE encodes fields positionally", + )); + } + let Some((payload_field, payload_ty)) = payload else { + return Err(syn::Error::new_spanned( + &input.ident, + "missing the payload field", + )); + }; + let (ok, err) = result_args(payload_ty).ok_or_else(|| { + syn::Error::new_spanned(payload_ty, "the payload field must be a `Result`") + })?; + let mut outcome_fn = None; + for attr in &input.attrs { + if !attr.path().is_ident("sso") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("outcome") { + outcome_fn = Some(meta.value()?.parse::()?); + Ok(()) + } else { + Err(meta.error("expected `outcome = `")) + } + })?; + } + + let name = &input.ident; + let wire = wire_path(); + let message = enum_path(); + let outcome = match outcome_fn { + Some(path) => quote! { #path(&self.#payload_field) }, + None => quote! { #wire::ResponseOutcome::from_payload(&self.#payload_field) }, + }; + Ok(quote! { + impl #wire::SsoResponse for #name { + fn outcome(&self) -> #wire::ResponseOutcome { + #outcome + } + type Ok = #ok; + type Err = #err; + fn new(responding_to: String, payload: Result<#ok, #err>) -> Self { + Self { responding_to, #payload_field: payload } + } + fn responding_to(&self) -> &str { + &self.responding_to + } + fn into_payload(self) -> Result<#ok, #err> { + self.#payload_field + } + fn into_message(self) -> #message { + #message::#name(self) + } + fn from_message(message: #message) -> Option { + match message { + #message::#name(response) => Some(response), + _ => None, + } + } + } + }) +} + +fn result_args(ty: &Type) -> Option<(&Type, &Type)> { + let Type::Path(path) = ty else { return None }; + let segment = path.path.segments.last()?; + if segment.ident != "Result" { + return None; + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let mut types = args.args.iter().filter_map(|arg| match arg { + GenericArgument::Type(ty) => Some(ty), + _ => None, + }); + let ok = types.next()?; + let err = types.next()?; + types.next().is_none().then_some((ok, err)) +} + +/// Pair and dispatch the handlers in one inherent implementation. +pub(crate) 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 = wire_path(); + let message = enum_path(); + let reply = reply_path(); + let runtime = quote!(crate::runtime::sso_service); + 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 variant = last_segment(&request_ty)?; + let name = &method.sig.ident; + let variant_name = variant.to_string(); + let stem = variant_name.strip_suffix("Request").ok_or_else(|| { + syn::Error::new_spanned(&request_ty, "request type must end in `Request`") + })?; + let expected_name = snake_case(stem); + if name != &expected_name { + return Err(syn::Error::new_spanned( + name, + format!("the method for `{variant}` must be named `{expected_name}`"), + )); + } + impls.push(quote! { + impl #wire::SsoRequest for #request_ty { + const NAME: &'static str = #expected_name; + type Response = #response_ty; + + fn into_message(self) -> #message { + use crate::host_logic::sso::messages::v1::AnyRequest; + AnyRequest::#variant(self).into() + } + + fn from_message(message: #message) -> Option { + use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; + match classify(message) { + Incoming::Request(AnyRequest::#variant(request)) => Some(request), + _ => None, + } + } + } + }); + 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) + } + }); + } + 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 Pat::Ident(_) = request.pat.as_ref() else { + 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 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" + ); + } + + #[test] + fn service_method_names_cannot_drift_from_client_actions() { + let item = syn::parse_quote! { + impl Service { + async fn sign_raw_legacy(&self, cx: &SsoRequestContext, request: SignRawWithLegacyAccountRequest) + -> SignRawWithLegacyAccountResponse { Ok(vec![]) } + } + }; + let error = expand_sso_service(item).unwrap_err(); + assert_eq!( + error.to_string(), + "the method for `SignRawWithLegacyAccountRequest` must be named `sign_raw_with_legacy_account`" + ); + } + + #[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: ) -> `" + ); + } + + #[test] + fn response_correlation_must_remain_first_on_the_wire() { + let input = syn::parse_quote! { + struct SignResponse { + payload: Result, String>, + responding_to: String, + } + }; + let error = derive_sso_response(input).unwrap_err(); + assert_eq!( + error.to_string(), + "`responding_to` must be the first field: SCALE encodes fields positionally" + ); + } +} 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..b2ac88075 --- /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::{FooRequest, FooResponse}; +use runtime::sso_service::SsoRequestContext; + +struct Service; + +#[truapi_macros::sso_service] +impl Service { + async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> 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..cb46ae6d8 --- /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: FooRequest) -> 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..d360670fc --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.stderr @@ -0,0 +1,16 @@ +error[E0599]: no variant or associated item named `BazRequest` found for enum `AnyRequest` in the current scope + --> tests/ui/sso/fail/request_without_variant.rs:20:58 + | +20 | async fn baz(&self, _: &SsoRequestContext, _request: BazRequest) -> FooResponse { + | ^^^^^^^^^^ variant or associated item not found in `AnyRequest` + | + ::: tests/ui/sso/fail/../support/wire.rs + | + | #[derive(truapi_macros::SsoWire)] + | ---------------------- variant or associated item `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 baz(&self, _: &SsoRequestContext, _request: BarRequest) -> FooResponse { + | 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..e5c159b91 --- /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: FooRequest) -> 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..c92e901e1 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.stderr @@ -0,0 +1,13 @@ +error[E0271]: type mismatch resolving `::Ok == &str` + --> tests/ui/sso/fail/wrong_payload.rs:9:1 + | +9 | #[truapi_macros::sso_service] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ type mismatch resolving `::Ok == &str` + | +note: expected this to be `&str` + --> tests/ui/sso/fail/../support/wire.rs + | + | pub payload: Result, + | ^^^ + = note: required for `Result<&str, String>` 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/fail/wrong_reply.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.rs new file mode 100644 index 000000000..6ba3e245d --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.rs @@ -0,0 +1,20 @@ +include!("../support/wire.rs"); +include!("../support/runtime.rs"); + +use host_logic::sso::messages::*; +use runtime::sso_service::{SsoReply, SsoRequestContext}; + +struct Service; + +#[truapi_macros::sso_service] +impl Service { + async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> BarResponse { + SsoReply::::from(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/wrong_reply.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.stderr new file mode 100644 index 000000000..53488e001 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.stderr @@ -0,0 +1,11 @@ +error[E0277]: the trait bound `SsoReply: From>` is not satisfied + --> tests/ui/sso/fail/wrong_reply.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 + = help: for that trait implementation, expected `Result`, found `SsoReply` + = note: required for `SsoReply` 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/explicit_pairing.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs new file mode 100644 index 000000000..2c6e0a987 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs @@ -0,0 +1,23 @@ +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: FooRequest) -> BarResponse { + Ok(1) + } + + async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> FooResponse { + Ok(2) + } +} + +fn main() { + fn check>() {} + check::(); +} 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..40ecb84a4 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs @@ -0,0 +1,44 @@ +include!("../support/wire.rs"); +include!("../support/runtime.rs"); + +use host_logic::sso::{messages::*, wire::ResponseOutcome}; +use runtime::sso_service::{SsoReply, SsoRequestContext}; + +struct Service; + +impl Service { + fn new() -> Self { + Self + } + + async fn value(&self, request: FooRequest) -> Result { + Ok(request.0) + } +} + +#[truapi_macros::sso_service] +impl Service { + async fn foo(&self, _: &SsoRequestContext, request: FooRequest) -> FooResponse { + let value = self.value(request).await?; + if value == 0 { + return Err("zero".into()); + } + Ok(value) + } + + async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> BarResponse { + SsoReply::::from(Ok(2)).with_outcome(ResponseOutcome) + } +} + +fn main() { + 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(FooRequest(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..5c83e2930 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs @@ -0,0 +1,24 @@ +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: FooRequest) -> FooResponse { + Ok(1) + } + + async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> FooResponse { + Ok(2) + } +} + +fn main() { + fn check>() {} + check::(); + check::(); +} 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..38fa50665 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/wire_without_service.rs @@ -0,0 +1,8 @@ +include!("../support/wire.rs"); + +fn main() { + use host_logic::sso::messages::{FooRequest, v1}; + let request = v1::RemoteMessage::FooRequest(Box::new(FooRequest(1))); + assert_eq!(request.name(), "foo"); + assert!(matches!(v1::classify(request), v1::Incoming::Request(_))); +} 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..7d0502369 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs @@ -0,0 +1,55 @@ +// Context and reply operations consumed by generated dispatch. +#[allow(dead_code)] +mod runtime { + pub mod authority { + pub struct AuthoritySession; + } + + pub mod sso_service { + use crate::host_logic::sso::wire::{ResponseOutcome, ResponsePayload, SsoResponse}; + + 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 { + payload: ResponsePayload, + outcome: Option, + } + + impl From> for SsoReply { + fn from(payload: ResponsePayload) -> Self { + Self { + payload, + outcome: None, + } + } + } + + impl SsoReply { + pub fn with_outcome(mut self, outcome: ResponseOutcome) -> Self { + self.outcome = Some(outcome); + self + } + + pub fn finish(self, id: &str) -> Answer { + let response = R::new(id.to_string(), self.payload); + let _outcome = self.outcome.unwrap_or_else(|| response.outcome()); + let _message = response.into_message(); + 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..419245d58 --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs @@ -0,0 +1,89 @@ +// 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::v1::RemoteMessage; + + pub trait SsoRequest: Sized { + const NAME: &'static str; + type Response: SsoResponse; + fn into_message(self) -> RemoteMessage; + fn from_message(message: RemoteMessage) -> Option; + } + + pub trait SsoResponse: Sized { + type Ok; + type Err: SsoError; + fn new(responding_to: String, payload: ResponsePayload) -> Self; + fn responding_to(&self) -> &str; + fn into_payload(self) -> ResponsePayload; + fn into_message(self) -> RemoteMessage; + fn from_message(message: RemoteMessage) -> Option; + fn outcome(&self) -> ResponseOutcome; + } + + pub type ResponsePayload = Result<::Ok, ::Err>; + + pub trait SsoError { + fn not_connected() -> Self; + } + + impl SsoError for String { + fn not_connected() -> Self { + "disconnected".into() + } + } + + pub struct ResponseOutcome; + + impl ResponseOutcome { + pub fn from_payload(_: &Result) -> Self { + Self + } + } + } + + pub mod messages { + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct FooRequest(pub u32); + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct BarRequest; + + #[derive(truapi_macros::SsoResponse)] + pub struct FooResponse { + pub responding_to: String, + pub payload: Result, + } + + #[derive(truapi_macros::SsoResponse)] + pub struct BarResponse { + pub responding_to: String, + pub payload: 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(FooResponse), + BarRequest(BarRequest), + BarResponse(BarResponse), + } + } + } + } +} From da3c61cf6427d7e937e4573634b2e29c7e790d18 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 8 Sep 2026 21:09:30 +0000 Subject: [PATCH 2/8] refactor(macros): keep each macro in its own module --- CLAUDE.md | 1 + README.md | 2 +- rust/crates/truapi-macros/README.md | 12 +- rust/crates/truapi-macros/src/lib.rs | 445 +------------ rust/crates/truapi-macros/src/service.rs | 34 + rust/crates/truapi-macros/src/sso.rs | 619 ------------------ rust/crates/truapi-macros/src/sso_common.rs | 45 ++ rust/crates/truapi-macros/src/sso_response.rs | 158 +++++ rust/crates/truapi-macros/src/sso_service.rs | 252 +++++++ rust/crates/truapi-macros/src/sso_wire.rs | 235 +++++++ .../truapi-macros/src/versioned_type.rs | 222 +++++++ rust/crates/truapi-macros/src/wire.rs | 144 ++++ 12 files changed, 1117 insertions(+), 1052 deletions(-) create mode 100644 rust/crates/truapi-macros/src/service.rs delete mode 100644 rust/crates/truapi-macros/src/sso.rs create mode 100644 rust/crates/truapi-macros/src/sso_common.rs create mode 100644 rust/crates/truapi-macros/src/sso_response.rs create mode 100644 rust/crates/truapi-macros/src/sso_service.rs create mode 100644 rust/crates/truapi-macros/src/sso_wire.rs create mode 100644 rust/crates/truapi-macros/src/versioned_type.rs create mode 100644 rust/crates/truapi-macros/src/wire.rs diff --git a/CLAUDE.md b/CLAUDE.md index c6599c4ca..844530791 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ rust/crates/ truapi-codegen/ rustdoc JSON → TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro; SsoWire/SsoResponse derives 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/README.md b/README.md index 4c7b7754e..42e8d1451 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ 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 the SSO handler and wire contracts. +See the [proc-macro guide](rust/crates/truapi-macros/README.md) for the SSO handler and wire contracts and their per-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 diff --git a/rust/crates/truapi-macros/README.md b/rust/crates/truapi-macros/README.md index c74694d0b..b189b1fdf 100644 --- a/rust/crates/truapi-macros/README.md +++ b/rust/crates/truapi-macros/README.md @@ -3,11 +3,17 @@ 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 | | --- | --- | --- | -| `SsoWire` | Hand-written `v1::RemoteMessage` enum | Request classification and wrapping, message names, and correlation helpers | -| `SsoResponse` | Response struct with `responding_to: String` followed by one `Result` field | Payload types and accessors, response construction, wire wrapping, and transcript outcome | -| `sso_service` | Dedicated inherent impl of SSO handlers | Request/response pairing, exhaustive dispatch, and handler reply conversion | +| [`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 | +| [`SsoResponse`](src/sso_response.rs) | Response struct with `responding_to: String` followed by one `Result` field | Payload types and accessors, response construction, wire wrapping, and transcript outcome | +| [`sso_service`](src/sso_service.rs) | Dedicated inherent impl of SSO handlers | Request/response pairing, exhaustive dispatch, and handler reply conversion | ## Handler contract diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index 122dc8f4f..a06be5f91 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -1,157 +1,24 @@ -//! Proc-macros for TrUAPI trait annotations. +//! Proc macros for TrUAPI annotations, versioned envelopes, and inter-host SSO. //! -//! `SsoWire`, `SsoResponse`, and `sso_service` describe `truapi-server`'s -//! inter-host SSO protocol. The derives expose wire classification and response -//! payloads; the service attribute pairs requests and responses from method -//! signatures in an inherent implementation and generates dispatch. They are -//! documented in [`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. +//! Each macro's implementation lives in its own module. Rust requires the +//! public proc-macro entry points to be defined at the crate root. -mod sso; +mod service; +mod sso_common; +mod sso_response; +mod sso_service; +mod sso_wire; +mod versioned_type; +mod wire; 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, -} - -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 }) - } -} /// 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. @@ -177,137 +44,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. @@ -334,130 +71,7 @@ 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(), - } -} - -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) + versioned_type::expand(item) } /// Classify the SSO wire enum's request, response, and disconnect variants. @@ -468,11 +82,7 @@ fn expand_versioned_enum(def: &VersionedEnum) -> syn::Result TokenStream { - let input = parse_macro_input!(item as syn::DeriveInput); - match sso::derive_sso_wire(input) { - Ok(tokens) => tokens.into(), - Err(err) => err.to_compile_error().into(), - } + sso_wire::expand(item) } /// Implement `SsoResponse` for a response struct made of `responding_to` and @@ -481,11 +91,7 @@ pub fn derive_sso_wire(item: TokenStream) -> TokenStream { /// `truapi-server`. #[proc_macro_derive(SsoResponse, attributes(sso))] pub fn derive_sso_response(item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as syn::DeriveInput); - match sso::derive_sso_response(input) { - Ok(tokens) => tokens.into(), - Err(err) => err.to_compile_error().into(), - } + sso_response::expand(item) } /// Define SSO handlers in a dedicated inherent implementation. @@ -502,24 +108,5 @@ pub fn derive_sso_response(item: TokenStream) -> TokenStream { /// types in `crate::runtime::sso_service`. It only works inside `truapi-server`. #[proc_macro_attribute] pub fn sso_service(args: TokenStream, item: TokenStream) -> 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) => sso::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(), - } + 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.rs b/rust/crates/truapi-macros/src/sso.rs deleted file mode 100644 index 38d649eeb..000000000 --- a/rust/crates/truapi-macros/src/sso.rs +++ /dev/null @@ -1,619 +0,0 @@ -//! Derives for the inter-host SSO protocol in `truapi-server`. -//! -//! `SsoWire` reads the hand-written `v1::RemoteMessage` enum and classifies -//! its variants. `sso_service` pairs requests with the wire responses named -//! in the handler signatures. `SsoResponse` reads a response's payload field. -//! These macros emit `crate::host_logic::sso::...` paths and only work inside -//! `truapi-server`. - -use proc_macro2::{Ident, TokenStream}; -use quote::{format_ident, quote}; -use syn::{ - Data, DeriveInput, Fields, FnArg, GenericArgument, ImplItem, ItemImpl, Pat, PathArguments, - Signature, Type, Variant, -}; - -const DISCONNECT_VARIANT: &str = "Disconnected"; - -fn wire_path() -> TokenStream { - quote!(crate::host_logic::sso::wire) -} - -fn enum_path() -> TokenStream { - quote!(crate::host_logic::sso::messages::v1::RemoteMessage) -} - -fn reply_path() -> TokenStream { - quote!(crate::runtime::sso_service::SsoReply) -} - -/// Expand `#[derive(SsoWire)]`. -pub(crate) 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") { - responses.push(ResponseVariant::parse(variant)?); - } 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 wire = wire_path(); - 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 response in &responses { - let variant = &response.variant; - let payload = &response.payload; - 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(#wire::SsoResponse::responding_to(response)) - }); - retarget_arms.push(quote! { - #enum_ident::#variant(response) => #enum_ident::#variant( - <#payload as #wire::SsoResponse>::new( - responding_to, - #wire::SsoResponse::into_payload(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, - }) - } -} - -struct ResponseVariant { - variant: Ident, - payload: Type, -} - -impl ResponseVariant { - fn parse(variant: &Variant) -> syn::Result { - Ok(Self { - variant: variant.ident.clone(), - payload: single_payload(variant)?.clone(), - }) - } -} - -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, - } -} - -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 -} - -/// Expand `#[derive(SsoResponse)]`. -pub(crate) fn derive_sso_response(input: DeriveInput) -> syn::Result { - let Data::Struct(data) = &input.data else { - return Err(syn::Error::new_spanned( - &input.ident, - "SsoResponse is derived on a response struct", - )); - }; - let Fields::Named(fields) = &data.fields else { - return Err(syn::Error::new_spanned( - &input.ident, - "expected named fields", - )); - }; - let mut payload = None; - let mut saw_responding_to = false; - for field in &fields.named { - let ident = field.ident.as_ref().expect("named field"); - if ident == "responding_to" { - saw_responding_to = true; - } else if payload.replace((ident.clone(), &field.ty)).is_some() { - return Err(syn::Error::new_spanned( - ident, - "a response has `responding_to` and exactly one payload field", - )); - } - } - if !saw_responding_to { - return Err(syn::Error::new_spanned( - &input.ident, - "missing `responding_to: String`", - )); - } - let first_is_responding_to = fields - .named - .first() - .and_then(|field| field.ident.as_ref()) - .is_some_and(|ident| ident == "responding_to"); - if !first_is_responding_to { - return Err(syn::Error::new_spanned( - &input.ident, - "`responding_to` must be the first field: SCALE encodes fields positionally", - )); - } - let Some((payload_field, payload_ty)) = payload else { - return Err(syn::Error::new_spanned( - &input.ident, - "missing the payload field", - )); - }; - let (ok, err) = result_args(payload_ty).ok_or_else(|| { - syn::Error::new_spanned(payload_ty, "the payload field must be a `Result`") - })?; - let mut outcome_fn = None; - for attr in &input.attrs { - if !attr.path().is_ident("sso") { - continue; - } - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("outcome") { - outcome_fn = Some(meta.value()?.parse::()?); - Ok(()) - } else { - Err(meta.error("expected `outcome = `")) - } - })?; - } - - let name = &input.ident; - let wire = wire_path(); - let message = enum_path(); - let outcome = match outcome_fn { - Some(path) => quote! { #path(&self.#payload_field) }, - None => quote! { #wire::ResponseOutcome::from_payload(&self.#payload_field) }, - }; - Ok(quote! { - impl #wire::SsoResponse for #name { - fn outcome(&self) -> #wire::ResponseOutcome { - #outcome - } - type Ok = #ok; - type Err = #err; - fn new(responding_to: String, payload: Result<#ok, #err>) -> Self { - Self { responding_to, #payload_field: payload } - } - fn responding_to(&self) -> &str { - &self.responding_to - } - fn into_payload(self) -> Result<#ok, #err> { - self.#payload_field - } - fn into_message(self) -> #message { - #message::#name(self) - } - fn from_message(message: #message) -> Option { - match message { - #message::#name(response) => Some(response), - _ => None, - } - } - } - }) -} - -fn result_args(ty: &Type) -> Option<(&Type, &Type)> { - let Type::Path(path) = ty else { return None }; - let segment = path.path.segments.last()?; - if segment.ident != "Result" { - return None; - } - let PathArguments::AngleBracketed(args) = &segment.arguments else { - return None; - }; - let mut types = args.args.iter().filter_map(|arg| match arg { - GenericArgument::Type(ty) => Some(ty), - _ => None, - }); - let ok = types.next()?; - let err = types.next()?; - types.next().is_none().then_some((ok, err)) -} - -/// Pair and dispatch the handlers in one inherent implementation. -pub(crate) 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 = wire_path(); - let message = enum_path(); - let reply = reply_path(); - let runtime = quote!(crate::runtime::sso_service); - 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 variant = last_segment(&request_ty)?; - let name = &method.sig.ident; - let variant_name = variant.to_string(); - let stem = variant_name.strip_suffix("Request").ok_or_else(|| { - syn::Error::new_spanned(&request_ty, "request type must end in `Request`") - })?; - let expected_name = snake_case(stem); - if name != &expected_name { - return Err(syn::Error::new_spanned( - name, - format!("the method for `{variant}` must be named `{expected_name}`"), - )); - } - impls.push(quote! { - impl #wire::SsoRequest for #request_ty { - const NAME: &'static str = #expected_name; - type Response = #response_ty; - - fn into_message(self) -> #message { - use crate::host_logic::sso::messages::v1::AnyRequest; - AnyRequest::#variant(self).into() - } - - fn from_message(message: #message) -> Option { - use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; - match classify(message) { - Incoming::Request(AnyRequest::#variant(request)) => Some(request), - _ => None, - } - } - } - }); - 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) - } - }); - } - 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 Pat::Ident(_) = request.pat.as_ref() else { - 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 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" - ); - } - - #[test] - fn service_method_names_cannot_drift_from_client_actions() { - let item = syn::parse_quote! { - impl Service { - async fn sign_raw_legacy(&self, cx: &SsoRequestContext, request: SignRawWithLegacyAccountRequest) - -> SignRawWithLegacyAccountResponse { Ok(vec![]) } - } - }; - let error = expand_sso_service(item).unwrap_err(); - assert_eq!( - error.to_string(), - "the method for `SignRawWithLegacyAccountRequest` must be named `sign_raw_with_legacy_account`" - ); - } - - #[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: ) -> `" - ); - } - - #[test] - fn response_correlation_must_remain_first_on_the_wire() { - let input = syn::parse_quote! { - struct SignResponse { - payload: Result, String>, - responding_to: String, - } - }; - let error = derive_sso_response(input).unwrap_err(); - assert_eq!( - error.to_string(), - "`responding_to` must be the first field: SCALE encodes fields positionally" - ); - } -} diff --git a/rust/crates/truapi-macros/src/sso_common.rs b/rust/crates/truapi-macros/src/sso_common.rs new file mode 100644 index 000000000..ad81e157d --- /dev/null +++ b/rust/crates/truapi-macros/src/sso_common.rs @@ -0,0 +1,45 @@ +//! Shared names and server paths used by the SSO macros. + +use proc_macro2::TokenStream; +use quote::quote; + +/// Server traits implemented by the SSO derives. +pub(super) fn wire_path() -> TokenStream { + quote!(crate::host_logic::sso::wire) +} + +/// Hand-written wire enum shared by SSO requests and responses. +pub(super) fn enum_path() -> TokenStream { + quote!(crate::host_logic::sso::messages::v1::RemoteMessage) +} + +/// Convert a request variant stem to its handler and client action name. +pub(super) 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/sso_response.rs b/rust/crates/truapi-macros/src/sso_response.rs new file mode 100644 index 000000000..69d116c87 --- /dev/null +++ b/rust/crates/truapi-macros/src/sso_response.rs @@ -0,0 +1,158 @@ +//! Payload access and wire wrapping for SSO response structs. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, parse_macro_input}; + +use crate::sso_common::{enum_path, wire_path}; + +/// 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_response(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +/// Expand `#[derive(SsoResponse)]`. +fn derive_sso_response(input: DeriveInput) -> syn::Result { + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "SsoResponse is derived on a response struct", + )); + }; + let Fields::Named(fields) = &data.fields else { + return Err(syn::Error::new_spanned( + &input.ident, + "expected named fields", + )); + }; + let mut payload = None; + let mut saw_responding_to = false; + for field in &fields.named { + let ident = field.ident.as_ref().expect("named field"); + if ident == "responding_to" { + saw_responding_to = true; + } else if payload.replace((ident.clone(), &field.ty)).is_some() { + return Err(syn::Error::new_spanned( + ident, + "a response has `responding_to` and exactly one payload field", + )); + } + } + if !saw_responding_to { + return Err(syn::Error::new_spanned( + &input.ident, + "missing `responding_to: String`", + )); + } + let first_is_responding_to = fields + .named + .first() + .and_then(|field| field.ident.as_ref()) + .is_some_and(|ident| ident == "responding_to"); + if !first_is_responding_to { + return Err(syn::Error::new_spanned( + &input.ident, + "`responding_to` must be the first field: SCALE encodes fields positionally", + )); + } + let Some((payload_field, payload_ty)) = payload else { + return Err(syn::Error::new_spanned( + &input.ident, + "missing the payload field", + )); + }; + let (ok, err) = result_args(payload_ty).ok_or_else(|| { + syn::Error::new_spanned(payload_ty, "the payload field must be a `Result`") + })?; + let mut outcome_fn = None; + for attr in &input.attrs { + if !attr.path().is_ident("sso") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("outcome") { + outcome_fn = Some(meta.value()?.parse::()?); + Ok(()) + } else { + Err(meta.error("expected `outcome = `")) + } + })?; + } + + let name = &input.ident; + let wire = wire_path(); + let message = enum_path(); + let outcome = match outcome_fn { + Some(path) => quote! { #path(&self.#payload_field) }, + None => quote! { #wire::ResponseOutcome::from_payload(&self.#payload_field) }, + }; + Ok(quote! { + impl #wire::SsoResponse for #name { + fn outcome(&self) -> #wire::ResponseOutcome { + #outcome + } + type Ok = #ok; + type Err = #err; + fn new(responding_to: String, payload: Result<#ok, #err>) -> Self { + Self { responding_to, #payload_field: payload } + } + fn responding_to(&self) -> &str { + &self.responding_to + } + fn into_payload(self) -> Result<#ok, #err> { + self.#payload_field + } + fn into_message(self) -> #message { + #message::#name(self) + } + fn from_message(message: #message) -> Option { + match message { + #message::#name(response) => Some(response), + _ => None, + } + } + } + }) +} + +fn result_args(ty: &Type) -> Option<(&Type, &Type)> { + let Type::Path(path) = ty else { return None }; + let segment = path.path.segments.last()?; + if segment.ident != "Result" { + return None; + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let mut types = args.args.iter().filter_map(|arg| match arg { + GenericArgument::Type(ty) => Some(ty), + _ => None, + }); + let ok = types.next()?; + let err = types.next()?; + types.next().is_none().then_some((ok, err)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_correlation_must_remain_first_on_the_wire() { + let input = syn::parse_quote! { + struct SignResponse { + payload: Result, String>, + responding_to: String, + } + }; + let error = derive_sso_response(input).unwrap_err(); + assert_eq!( + error.to_string(), + "`responding_to` must be the first field: SCALE encodes fields positionally" + ); + } +} 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..2e30795ee --- /dev/null +++ b/rust/crates/truapi-macros/src/sso_service.rs @@ -0,0 +1,252 @@ +//! Request/response pairing and dispatch for an inherent SSO handler implementation. + +use proc_macro2::{Ident, TokenStream}; +use quote::quote; +use syn::{FnArg, ImplItem, ItemImpl, Pat, Signature, Type, parse_macro_input}; + +use crate::sso_common::{enum_path, snake_case, wire_path}; + +/// 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(), + } +} + +fn reply_path() -> TokenStream { + quote!(crate::runtime::sso_service::SsoReply) +} + +/// 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 = wire_path(); + let message = enum_path(); + let reply = reply_path(); + let runtime = quote!(crate::runtime::sso_service); + 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 variant = last_segment(&request_ty)?; + let name = &method.sig.ident; + let variant_name = variant.to_string(); + let stem = variant_name.strip_suffix("Request").ok_or_else(|| { + syn::Error::new_spanned(&request_ty, "request type must end in `Request`") + })?; + let expected_name = snake_case(stem); + if name != &expected_name { + return Err(syn::Error::new_spanned( + name, + format!("the method for `{variant}` must be named `{expected_name}`"), + )); + } + impls.push(quote! { + impl #wire::SsoRequest for #request_ty { + const NAME: &'static str = #expected_name; + type Response = #response_ty; + + fn into_message(self) -> #message { + use crate::host_logic::sso::messages::v1::AnyRequest; + AnyRequest::#variant(self).into() + } + + fn from_message(message: #message) -> Option { + use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; + match classify(message) { + Incoming::Request(AnyRequest::#variant(request)) => Some(request), + _ => None, + } + } + } + }); + 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) + } + }); + } + 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 Pat::Ident(_) = request.pat.as_ref() else { + 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_method_names_cannot_drift_from_client_actions() { + let item = syn::parse_quote! { + impl Service { + async fn sign_raw_legacy(&self, cx: &SsoRequestContext, request: SignRawWithLegacyAccountRequest) + -> SignRawWithLegacyAccountResponse { Ok(vec![]) } + } + }; + let error = expand_sso_service(item).unwrap_err(); + assert_eq!( + error.to_string(), + "the method for `SignRawWithLegacyAccountRequest` must be named `sign_raw_with_legacy_account`" + ); + } + + #[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..687864f5c --- /dev/null +++ b/rust/crates/truapi-macros/src/sso_wire.rs @@ -0,0 +1,235 @@ +//! 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, +}; + +use crate::sso_common::{snake_case, wire_path}; + +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") { + responses.push(ResponseVariant::parse(variant)?); + } 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 wire = wire_path(); + 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 response in &responses { + let variant = &response.variant; + let payload = &response.payload; + 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(#wire::SsoResponse::responding_to(response)) + }); + retarget_arms.push(quote! { + #enum_ident::#variant(response) => #enum_ident::#variant( + <#payload as #wire::SsoResponse>::new( + responding_to, + #wire::SsoResponse::into_payload(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, + }) + } +} + +struct ResponseVariant { + variant: Ident, + payload: Type, +} + +impl ResponseVariant { + fn parse(variant: &Variant) -> syn::Result { + Ok(Self { + variant: variant.ident.clone(), + payload: single_payload(variant)?.clone(), + }) + } +} + +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, + } +} 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 +} From 8f87461d646ec5404ac7b48db2fd3bb59da37874 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 04:15:20 +0000 Subject: [PATCH 3/8] refactor(macros): generate SSO conversions for shared responses --- CLAUDE.md | 2 +- README.md | 2 +- rust/crates/truapi-macros/README.md | 26 +-- rust/crates/truapi-macros/src/lib.rs | 15 +- rust/crates/truapi-macros/src/sso_common.rs | 2 +- rust/crates/truapi-macros/src/sso_response.rs | 158 ------------------ rust/crates/truapi-macros/src/sso_service.rs | 18 +- rust/crates/truapi-macros/src/sso_wire.rs | 36 +--- ...g_reply.rs => response_without_variant.rs} | 7 +- .../sso/fail/response_without_variant.stderr | 10 ++ .../tests/ui/sso/fail/wrong_payload.stderr | 14 +- .../tests/ui/sso/fail/wrong_reply.stderr | 11 -- .../tests/ui/sso/pass/explicit_pairing.rs | 6 + .../tests/ui/sso/pass/shared_response.rs | 6 + .../tests/ui/sso/support/runtime.rs | 31 ++-- .../tests/ui/sso/support/wire.rs | 37 ++-- 16 files changed, 112 insertions(+), 269 deletions(-) delete mode 100644 rust/crates/truapi-macros/src/sso_response.rs rename rust/crates/truapi-macros/tests/ui/sso/fail/{wrong_reply.rs => response_without_variant.rs} (74%) create mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.stderr delete mode 100644 rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.stderr diff --git a/CLAUDE.md b/CLAUDE.md index 844530791..2017a7167 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ 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; SsoWire/SsoResponse derives and + 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, ...) diff --git a/README.md b/README.md index 42e8d1451..d6a684304 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ 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 the SSO handler and wire contracts and their per-macro implementation modules. +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 diff --git a/rust/crates/truapi-macros/README.md b/rust/crates/truapi-macros/README.md index b189b1fdf..3fce475d2 100644 --- a/rust/crates/truapi-macros/README.md +++ b/rust/crates/truapi-macros/README.md @@ -12,15 +12,16 @@ the thin public entry points, which Rust requires at the proc-macro crate root. | [`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 | -| [`SsoResponse`](src/sso_response.rs) | Response struct with `responding_to: String` followed by one `Result` field | Payload types and accessors, response construction, wire wrapping, and transcript outcome | -| [`sso_service`](src/sso_service.rs) | Dedicated inherent impl of SSO handlers | Request/response pairing, exhaustive dispatch, and handler reply conversion | +| [`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 parameter names a wire -request type and its return type names the corresponding wire response: +request type 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( @@ -36,19 +37,22 @@ impl SigningHostSsoService { ``` The method name is the request type's snake-case stem: `GetAccountAliasRequest` -requires `get_account_alias`. The named response defines the pairing, including -when two handlers share one response type. Constructors and internal helpers -belong in a separate, unannotated impl. +requires `get_account_alias`. 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 response's ordinary `Result` payload or an explicit `SsoReply` -with a local transcript outcome. An inner async block preserves `?` and early -returns; `.into()` performs the reply conversion. +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 the transcript outcome. Missing +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. ## Server integration diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index a06be5f91..fcf94d715 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -5,7 +5,6 @@ mod service; mod sso_common; -mod sso_response; mod sso_service; mod sso_wire; mod versioned_type; @@ -85,21 +84,13 @@ pub fn derive_sso_wire(item: TokenStream) -> TokenStream { sso_wire::expand(item) } -/// Implement `SsoResponse` for a response struct made of `responding_to` and -/// one `Result` payload field. `#[sso(outcome = path)]` swaps the -/// transcript classification for a bespoke function. Only valid inside -/// `truapi-server`. -#[proc_macro_derive(SsoResponse, attributes(sso))] -pub fn derive_sso_response(item: TokenStream) -> TokenStream { - sso_response::expand(item) -} - /// Define SSO handlers in a dedicated inherent implementation. /// /// Every method must be `async fn name(&self, cx: &SsoRequestContext, request: -/// ) -> `. Each signature supplies `SsoRequest` pairing; +/// ) -> `, where the named response aliases its `Result` +/// payload and selects the wire variant. 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 +/// 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. diff --git a/rust/crates/truapi-macros/src/sso_common.rs b/rust/crates/truapi-macros/src/sso_common.rs index ad81e157d..1ef475848 100644 --- a/rust/crates/truapi-macros/src/sso_common.rs +++ b/rust/crates/truapi-macros/src/sso_common.rs @@ -3,7 +3,7 @@ use proc_macro2::TokenStream; use quote::quote; -/// Server traits implemented by the SSO derives. +/// Request contract implemented by the service macro. pub(super) fn wire_path() -> TokenStream { quote!(crate::host_logic::sso::wire) } diff --git a/rust/crates/truapi-macros/src/sso_response.rs b/rust/crates/truapi-macros/src/sso_response.rs deleted file mode 100644 index 69d116c87..000000000 --- a/rust/crates/truapi-macros/src/sso_response.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Payload access and wire wrapping for SSO response structs. - -use proc_macro2::TokenStream; -use quote::quote; -use syn::{Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, parse_macro_input}; - -use crate::sso_common::{enum_path, wire_path}; - -/// 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_response(input) { - Ok(tokens) => tokens.into(), - Err(err) => err.to_compile_error().into(), - } -} - -/// Expand `#[derive(SsoResponse)]`. -fn derive_sso_response(input: DeriveInput) -> syn::Result { - let Data::Struct(data) = &input.data else { - return Err(syn::Error::new_spanned( - &input.ident, - "SsoResponse is derived on a response struct", - )); - }; - let Fields::Named(fields) = &data.fields else { - return Err(syn::Error::new_spanned( - &input.ident, - "expected named fields", - )); - }; - let mut payload = None; - let mut saw_responding_to = false; - for field in &fields.named { - let ident = field.ident.as_ref().expect("named field"); - if ident == "responding_to" { - saw_responding_to = true; - } else if payload.replace((ident.clone(), &field.ty)).is_some() { - return Err(syn::Error::new_spanned( - ident, - "a response has `responding_to` and exactly one payload field", - )); - } - } - if !saw_responding_to { - return Err(syn::Error::new_spanned( - &input.ident, - "missing `responding_to: String`", - )); - } - let first_is_responding_to = fields - .named - .first() - .and_then(|field| field.ident.as_ref()) - .is_some_and(|ident| ident == "responding_to"); - if !first_is_responding_to { - return Err(syn::Error::new_spanned( - &input.ident, - "`responding_to` must be the first field: SCALE encodes fields positionally", - )); - } - let Some((payload_field, payload_ty)) = payload else { - return Err(syn::Error::new_spanned( - &input.ident, - "missing the payload field", - )); - }; - let (ok, err) = result_args(payload_ty).ok_or_else(|| { - syn::Error::new_spanned(payload_ty, "the payload field must be a `Result`") - })?; - let mut outcome_fn = None; - for attr in &input.attrs { - if !attr.path().is_ident("sso") { - continue; - } - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("outcome") { - outcome_fn = Some(meta.value()?.parse::()?); - Ok(()) - } else { - Err(meta.error("expected `outcome = `")) - } - })?; - } - - let name = &input.ident; - let wire = wire_path(); - let message = enum_path(); - let outcome = match outcome_fn { - Some(path) => quote! { #path(&self.#payload_field) }, - None => quote! { #wire::ResponseOutcome::from_payload(&self.#payload_field) }, - }; - Ok(quote! { - impl #wire::SsoResponse for #name { - fn outcome(&self) -> #wire::ResponseOutcome { - #outcome - } - type Ok = #ok; - type Err = #err; - fn new(responding_to: String, payload: Result<#ok, #err>) -> Self { - Self { responding_to, #payload_field: payload } - } - fn responding_to(&self) -> &str { - &self.responding_to - } - fn into_payload(self) -> Result<#ok, #err> { - self.#payload_field - } - fn into_message(self) -> #message { - #message::#name(self) - } - fn from_message(message: #message) -> Option { - match message { - #message::#name(response) => Some(response), - _ => None, - } - } - } - }) -} - -fn result_args(ty: &Type) -> Option<(&Type, &Type)> { - let Type::Path(path) = ty else { return None }; - let segment = path.path.segments.last()?; - if segment.ident != "Result" { - return None; - } - let PathArguments::AngleBracketed(args) = &segment.arguments else { - return None; - }; - let mut types = args.args.iter().filter_map(|arg| match arg { - GenericArgument::Type(ty) => Some(ty), - _ => None, - }); - let ok = types.next()?; - let err = types.next()?; - types.next().is_none().then_some((ok, err)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn response_correlation_must_remain_first_on_the_wire() { - let input = syn::parse_quote! { - struct SignResponse { - payload: Result, String>, - responding_to: String, - } - }; - let error = derive_sso_response(input).unwrap_err(); - assert_eq!( - error.to_string(), - "`responding_to` must be the first field: SCALE encodes fields positionally" - ); - } -} diff --git a/rust/crates/truapi-macros/src/sso_service.rs b/rust/crates/truapi-macros/src/sso_service.rs index 2e30795ee..5b0bbc408 100644 --- a/rust/crates/truapi-macros/src/sso_service.rs +++ b/rust/crates/truapi-macros/src/sso_service.rs @@ -71,6 +71,7 @@ fn expand_sso_service(mut item: ItemImpl) -> syn::Result { (async move #body).await.into() }); let variant = last_segment(&request_ty)?; + let response_variant = last_segment(&response_ty)?; let name = &method.sig.ident; let variant_name = variant.to_string(); let stem = variant_name.strip_suffix("Request").ok_or_else(|| { @@ -88,6 +89,21 @@ fn expand_sso_service(mut item: ItemImpl) -> syn::Result { const NAME: &'static str = #expected_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() @@ -108,7 +124,7 @@ fn expand_sso_service(mut item: ItemImpl) -> syn::Result { Some(cx) => self.#name(cx, request).await, None => Err(#wire::SsoError::not_connected()).into(), }; - reply.finish(&message_id) + reply.finish(&message_id, <#request_ty as #wire::SsoRequest>::response_into_message) } }); } diff --git a/rust/crates/truapi-macros/src/sso_wire.rs b/rust/crates/truapi-macros/src/sso_wire.rs index 687864f5c..f85d02ef9 100644 --- a/rust/crates/truapi-macros/src/sso_wire.rs +++ b/rust/crates/truapi-macros/src/sso_wire.rs @@ -6,7 +6,7 @@ use syn::{ Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, Variant, parse_macro_input, }; -use crate::sso_common::{snake_case, wire_path}; +use crate::sso_common::snake_case; const DISCONNECT_VARIANT: &str = "Disconnected"; @@ -44,7 +44,8 @@ fn derive_sso_wire(input: DeriveInput) -> syn::Result { } else if name.ends_with("Request") { requests.push(RequestVariant::parse(variant)?); } else if name.ends_with("Response") { - responses.push(ResponseVariant::parse(variant)?); + single_payload(variant)?; + responses.push(variant.ident.clone()); } else { return Err(syn::Error::new_spanned( &variant.ident, @@ -59,7 +60,6 @@ fn derive_sso_wire(input: DeriveInput) -> syn::Result { )); } - let wire = wire_path(); let disconnect = format_ident!("{DISCONNECT_VARIANT}"); let mut any_variants = Vec::new(); let mut classify_arms = Vec::new(); @@ -93,22 +93,18 @@ fn derive_sso_wire(input: DeriveInput) -> syn::Result { let name = snake_case(stem); name_arms.push(quote! { #enum_ident::#variant(_) => #name }); } - for response in &responses { - let variant = &response.variant; - let payload = &response.payload; + 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(#wire::SsoResponse::responding_to(response)) + #enum_ident::#variant(response) => Some(&response.responding_to) }); retarget_arms.push(quote! { - #enum_ident::#variant(response) => #enum_ident::#variant( - <#payload as #wire::SsoResponse>::new( - responding_to, - #wire::SsoResponse::into_payload(response), - ), - ) + #enum_ident::#variant(mut response) => { + response.responding_to = responding_to; + #enum_ident::#variant(response) + } }); } Ok(quote! { @@ -195,20 +191,6 @@ impl RequestVariant { } } -struct ResponseVariant { - variant: Ident, - payload: Type, -} - -impl ResponseVariant { - fn parse(variant: &Variant) -> syn::Result { - Ok(Self { - variant: variant.ident.clone(), - payload: single_payload(variant)?.clone(), - }) - } -} - fn single_payload(variant: &Variant) -> syn::Result<&Type> { match &variant.fields { Fields::Unnamed(fields) if fields.unnamed.len() == 1 => Ok(&fields.unnamed[0].ty), diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.rs similarity index 74% rename from rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.rs rename to rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.rs index 6ba3e245d..a1c50e904 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.rs @@ -2,14 +2,15 @@ include!("../support/wire.rs"); include!("../support/runtime.rs"); use host_logic::sso::messages::*; -use runtime::sso_service::{SsoReply, SsoRequestContext}; +use runtime::sso_service::SsoRequestContext; +type MissingResponse = FooResponse; struct Service; #[truapi_macros::sso_service] impl Service { - async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> BarResponse { - SsoReply::::from(Ok(1)) + async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> MissingResponse { + Ok(1) } async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> BarResponse { 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..53bf454fc --- /dev/null +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.stderr @@ -0,0 +1,10 @@ +error[E0599]: no variant or associated item named `MissingResponse` found for enum `messages::v1::RemoteMessage` in the current scope + --> tests/ui/sso/fail/response_without_variant.rs:12:73 + | +12 | async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> MissingResponse { + | ^^^^^^^^^^^^^^^ variant or associated item not found in `messages::v1::RemoteMessage` + | + ::: tests/ui/sso/fail/../support/wire.rs + | + | pub enum RemoteMessage { + | ---------------------- variant or associated item `MissingResponse` not found for this enum 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 index c92e901e1..6191d2e5a 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.stderr +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.stderr @@ -1,13 +1,11 @@ -error[E0271]: type mismatch resolving `::Ok == &str` +error[E0277]: the trait bound `SsoReply>: From>` is not satisfied --> tests/ui/sso/fail/wrong_payload.rs:9:1 | 9 | #[truapi_macros::sso_service] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ type mismatch resolving `::Ok == &str` + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound | -note: expected this to be `&str` - --> tests/ui/sso/fail/../support/wire.rs - | - | pub payload: Result, - | ^^^ - = note: required for `Result<&str, String>` to implement `Into>` + = help: the trait `From>` is not implemented for `SsoReply>` + but trait `From>` is implemented for it + = 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/fail/wrong_reply.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.stderr deleted file mode 100644 index 53488e001..000000000 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_reply.stderr +++ /dev/null @@ -1,11 +0,0 @@ -error[E0277]: the trait bound `SsoReply: From>` is not satisfied - --> tests/ui/sso/fail/wrong_reply.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 - = help: for that trait implementation, expected `Result`, found `SsoReply` - = note: required for `SsoReply` 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/explicit_pairing.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs index 2c6e0a987..c84802548 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs @@ -20,4 +20,10 @@ impl Service { fn main() { fn check>() {} check::(); + let response = FooRequest::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()); } 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 index 5c83e2930..414399c89 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs @@ -21,4 +21,10 @@ fn main() { fn check>() {} check::(); check::(); + let response = BarRequest::response_into_message(Response { + responding_to: "m-1".into(), + payload: Ok(7), + }); + assert!(matches!(response, v1::RemoteMessage::FooResponse(_))); + assert!(FooRequest::response_from_message(response).is_some()); } diff --git a/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs b/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs index 7d0502369..ef53a3748 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs @@ -6,7 +6,8 @@ mod runtime { } pub mod sso_service { - use crate::host_logic::sso::wire::{ResponseOutcome, ResponsePayload, SsoResponse}; + use crate::host_logic::sso::messages::{Response, v1}; + use crate::host_logic::sso::wire::{ResponseOutcome, SsoError}; pub struct SsoRequestContext; @@ -24,13 +25,13 @@ mod runtime { pub struct Answer; - pub struct SsoReply { - payload: ResponsePayload, + pub struct SsoReply

{ + payload: P, outcome: Option, } - impl From> for SsoReply { - fn from(payload: ResponsePayload) -> Self { + impl

From

for SsoReply

{ + fn from(payload: P) -> Self { Self { payload, outcome: None, @@ -38,16 +39,26 @@ mod runtime { } } - impl SsoReply { + impl

SsoReply

{ pub fn with_outcome(mut self, outcome: ResponseOutcome) -> Self { self.outcome = Some(outcome); self } + } - pub fn finish(self, id: &str) -> Answer { - let response = R::new(id.to_string(), self.payload); - let _outcome = self.outcome.unwrap_or_else(|| response.outcome()); - let _message = response.into_message(); + impl SsoReply> { + pub fn finish( + self, + id: &str, + wrap: impl FnOnce(Response>) -> v1::RemoteMessage, + ) -> Answer { + let _outcome = self + .outcome + .unwrap_or_else(|| ResponseOutcome::from_payload(&self.payload)); + let _message = wrap(Response { + responding_to: id.to_string(), + payload: self.payload, + }); 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 index 419245d58..5cf45b8ee 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs @@ -3,28 +3,19 @@ mod host_logic { pub mod sso { pub mod wire { - use super::messages::v1::RemoteMessage; + use super::messages::{Response, v1::RemoteMessage}; pub trait SsoRequest: Sized { const NAME: &'static str; - type Response: SsoResponse; + type Response; fn into_message(self) -> RemoteMessage; fn from_message(message: RemoteMessage) -> Option; + fn response_into_message(response: Response) -> RemoteMessage; + fn response_from_message( + message: RemoteMessage, + ) -> Option>; } - pub trait SsoResponse: Sized { - type Ok; - type Err: SsoError; - fn new(responding_to: String, payload: ResponsePayload) -> Self; - fn responding_to(&self) -> &str; - fn into_payload(self) -> ResponsePayload; - fn into_message(self) -> RemoteMessage; - fn from_message(message: RemoteMessage) -> Option; - fn outcome(&self) -> ResponseOutcome; - } - - pub type ResponsePayload = Result<::Ok, ::Err>; - pub trait SsoError { fn not_connected() -> Self; } @@ -51,17 +42,13 @@ mod host_logic { #[derive(Debug, Clone, PartialEq, Eq)] pub struct BarRequest; - #[derive(truapi_macros::SsoResponse)] - pub struct FooResponse { + pub struct Response

{ pub responding_to: String, - pub payload: Result, + pub payload: P, } - #[derive(truapi_macros::SsoResponse)] - pub struct BarResponse { - pub responding_to: String, - pub payload: Result, - } + pub type FooResponse = Result; + pub type BarResponse = Result; pub struct RemoteMessage { pub message_id: String, @@ -79,9 +66,9 @@ mod host_logic { pub enum RemoteMessage { Disconnected, FooRequest(Box), - FooResponse(FooResponse), + FooResponse(Response), BarRequest(BarRequest), - BarResponse(BarResponse), + BarResponse(Response), } } } From ceaa2cce2824bba9129aa573de8f0eaf198a20dc Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 05:06:06 +0000 Subject: [PATCH 4/8] refactor(sso): infer request variants from handler names --- rust/crates/truapi-macros/README.md | 17 ++++-- rust/crates/truapi-macros/src/lib.rs | 3 +- rust/crates/truapi-macros/src/sso_service.rs | 53 ++++++------------- .../tests/ui/sso/fail/missing_handler.rs | 4 +- .../ui/sso/fail/request_without_variant.rs | 2 +- .../sso/fail/request_without_variant.stderr | 6 +-- .../ui/sso/fail/response_without_variant.rs | 2 +- .../sso/fail/response_without_variant.stderr | 6 +-- .../tests/ui/sso/fail/wrong_payload.rs | 2 +- .../tests/ui/sso/pass/explicit_pairing.rs | 6 +-- .../tests/ui/sso/pass/handlers.rs | 6 +-- .../tests/ui/sso/pass/shared_response.rs | 6 +-- .../tests/ui/sso/pass/wire_without_service.rs | 4 +- .../tests/ui/sso/support/wire.rs | 5 +- 14 files changed, 53 insertions(+), 69 deletions(-) diff --git a/rust/crates/truapi-macros/README.md b/rust/crates/truapi-macros/README.md index 3fce475d2..534a72803 100644 --- a/rust/crates/truapi-macros/README.md +++ b/rust/crates/truapi-macros/README.md @@ -16,8 +16,9 @@ the thin public entry points, which Rust requires at the proc-macro crate root. ## Handler contract -Every method in the annotated impl is an endpoint. Its parameter names a wire -request type and its return type names the response's `Result` payload: +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; @@ -27,7 +28,7 @@ impl SigningHostSsoService { async fn get_account_alias( &self, cx: &SsoRequestContext, - request: GetAccountAliasRequest, + request: ProductRequest, ) -> GetAccountAliasResponse { self.signing_host .account_alias(&cx.call, &cx.session, request) @@ -36,8 +37,9 @@ impl SigningHostSsoService { } ``` -The method name is the request type's snake-case stem: `GetAccountAliasRequest` -requires `get_account_alias`. The return type's name selects the wire response +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. @@ -55,6 +57,11 @@ 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 diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index fcf94d715..6b0973e48 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -88,7 +88,8 @@ pub fn derive_sso_wire(item: TokenStream) -> TokenStream { /// /// Every method must be `async fn name(&self, cx: &SsoRequestContext, request: /// ) -> `, where the named response aliases its `Result` -/// payload and selects the wire variant. Each signature supplies `SsoRequest` pairing; +/// 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. diff --git a/rust/crates/truapi-macros/src/sso_service.rs b/rust/crates/truapi-macros/src/sso_service.rs index 5b0bbc408..ac56561fb 100644 --- a/rust/crates/truapi-macros/src/sso_service.rs +++ b/rust/crates/truapi-macros/src/sso_service.rs @@ -1,10 +1,10 @@ //! Request/response pairing and dispatch for an inherent SSO handler implementation. use proc_macro2::{Ident, TokenStream}; -use quote::quote; +use quote::{format_ident, quote}; use syn::{FnArg, ImplItem, ItemImpl, Pat, Signature, Type, parse_macro_input}; -use crate::sso_common::{enum_path, snake_case, wire_path}; +use crate::sso_common::{enum_path, wire_path}; /// Parse the macro input and emit generated code or a compiler diagnostic. pub(super) fn expand( @@ -70,23 +70,23 @@ fn expand_sso_service(mut item: ItemImpl) -> syn::Result { method.block = syn::parse_quote!({ (async move #body).await.into() }); - let variant = last_segment(&request_ty)?; let response_variant = last_segment(&response_ty)?; let name = &method.sig.ident; - let variant_name = variant.to_string(); - let stem = variant_name.strip_suffix("Request").ok_or_else(|| { - syn::Error::new_spanned(&request_ty, "request type must end in `Request`") - })?; - let expected_name = snake_case(stem); - if name != &expected_name { - return Err(syn::Error::new_spanned( - name, - format!("the method for `{variant}` must be named `{expected_name}`"), - )); - } + let method_name = name.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 = #expected_name; + const NAME: &'static str = #method_name; type Response = #response_ty; fn response_into_message( @@ -108,14 +108,6 @@ fn expand_sso_service(mut item: ItemImpl) -> syn::Result { use crate::host_logic::sso::messages::v1::AnyRequest; AnyRequest::#variant(self).into() } - - fn from_message(message: #message) -> Option { - use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; - match classify(message) { - Incoming::Request(AnyRequest::#variant(request)) => Some(request), - _ => None, - } - } } }); arms.push(quote! { @@ -236,21 +228,6 @@ fn last_segment(ty: &Type) -> syn::Result { mod tests { use super::*; - #[test] - fn service_method_names_cannot_drift_from_client_actions() { - let item = syn::parse_quote! { - impl Service { - async fn sign_raw_legacy(&self, cx: &SsoRequestContext, request: SignRawWithLegacyAccountRequest) - -> SignRawWithLegacyAccountResponse { Ok(vec![]) } - } - }; - let error = expand_sso_service(item).unwrap_err(); - assert_eq!( - error.to_string(), - "the method for `SignRawWithLegacyAccountRequest` must be named `sign_raw_with_legacy_account`" - ); - } - #[test] fn service_requires_an_explicit_wire_response() { let item = syn::parse_quote! { 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 index b2ac88075..dc5ac2069 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/missing_handler.rs @@ -1,14 +1,14 @@ include!("../support/wire.rs"); include!("../support/runtime.rs"); -use host_logic::sso::messages::{FooRequest, FooResponse}; +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: FooRequest) -> FooResponse { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> FooResponse { Ok(1) } } 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 index cb46ae6d8..8bc7eaa22 100644 --- 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 @@ -9,7 +9,7 @@ struct BazRequest; #[truapi_macros::sso_service] impl Service { - async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> FooResponse { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> FooResponse { Ok(1) } 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 index d360670fc..e146472e8 100644 --- 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 @@ -1,8 +1,8 @@ error[E0599]: no variant or associated item named `BazRequest` found for enum `AnyRequest` in the current scope - --> tests/ui/sso/fail/request_without_variant.rs:20:58 + --> tests/ui/sso/fail/request_without_variant.rs:20:14 | 20 | async fn baz(&self, _: &SsoRequestContext, _request: BazRequest) -> FooResponse { - | ^^^^^^^^^^ variant or associated item not found in `AnyRequest` + | ^^^ variant or associated item not found in `AnyRequest` | ::: tests/ui/sso/fail/../support/wire.rs | @@ -12,5 +12,5 @@ error[E0599]: no variant or associated item named `BazRequest` found for enum `A help: there is a variant with a similar name | 20 - async fn baz(&self, _: &SsoRequestContext, _request: BazRequest) -> FooResponse { -20 + async fn baz(&self, _: &SsoRequestContext, _request: BarRequest) -> 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 index a1c50e904..0816d9ea1 100644 --- 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 @@ -9,7 +9,7 @@ struct Service; #[truapi_macros::sso_service] impl Service { - async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> MissingResponse { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> MissingResponse { Ok(1) } 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 index 53bf454fc..c09dac388 100644 --- 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 @@ -1,8 +1,8 @@ error[E0599]: no variant or associated item named `MissingResponse` found for enum `messages::v1::RemoteMessage` in the current scope - --> tests/ui/sso/fail/response_without_variant.rs:12:73 + --> tests/ui/sso/fail/response_without_variant.rs:12:75 | -12 | async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> MissingResponse { - | ^^^^^^^^^^^^^^^ variant or associated item not found in `messages::v1::RemoteMessage` +12 | async fn foo(&self, _: &SsoRequestContext, _request: Request) -> MissingResponse { + | ^^^^^^^^^^^^^^^ variant or associated item not found in `messages::v1::RemoteMessage` | ::: tests/ui/sso/fail/../support/wire.rs | 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 index e5c159b91..2b620778c 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.rs @@ -8,7 +8,7 @@ struct Service; #[truapi_macros::sso_service] impl Service { - async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> FooResponse { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> FooResponse { Ok("wrong payload type") } diff --git a/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs index c84802548..cb1eb44c7 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs @@ -8,7 +8,7 @@ struct Service; #[truapi_macros::sso_service] impl Service { - async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> BarResponse { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> BarResponse { Ok(1) } @@ -19,8 +19,8 @@ impl Service { fn main() { fn check>() {} - check::(); - let response = FooRequest::response_into_message(Response { + check::>(); + let response = Request::::response_into_message(Response { responding_to: "m-1".into(), payload: Ok(7), }); diff --git a/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs index 40ecb84a4..ed1cfcf0e 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs @@ -11,14 +11,14 @@ impl Service { Self } - async fn value(&self, request: FooRequest) -> Result { + async fn value(&self, request: Request) -> Result { Ok(request.0) } } #[truapi_macros::sso_service] impl Service { - async fn foo(&self, _: &SsoRequestContext, request: FooRequest) -> FooResponse { + async fn foo(&self, _: &SsoRequestContext, request: Request) -> FooResponse { let value = self.value(request).await?; if value == 0 { return Err("zero".into()); @@ -38,7 +38,7 @@ fn main() { None, RemoteMessage { message_id: "m-1".into(), - data: RemoteMessageData::V1(v1::RemoteMessage::FooRequest(Box::new(FooRequest(1)))), + 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 index 414399c89..4a8038708 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs @@ -8,7 +8,7 @@ struct Service; #[truapi_macros::sso_service] impl Service { - async fn foo(&self, _: &SsoRequestContext, _request: FooRequest) -> FooResponse { + async fn foo(&self, _: &SsoRequestContext, _request: Request) -> FooResponse { Ok(1) } @@ -19,12 +19,12 @@ impl Service { fn main() { fn check>() {} - check::(); + check::>(); check::(); let response = BarRequest::response_into_message(Response { responding_to: "m-1".into(), payload: Ok(7), }); assert!(matches!(response, v1::RemoteMessage::FooResponse(_))); - assert!(FooRequest::response_from_message(response).is_some()); + 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 index 38fa50665..d595eecf5 100644 --- 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 @@ -1,8 +1,8 @@ include!("../support/wire.rs"); fn main() { - use host_logic::sso::messages::{FooRequest, v1}; - let request = v1::RemoteMessage::FooRequest(Box::new(FooRequest(1))); + use host_logic::sso::messages::{Request, v1}; + let request = v1::RemoteMessage::FooRequest(Box::new(Request(1))); assert_eq!(request.name(), "foo"); assert!(matches!(v1::classify(request), v1::Incoming::Request(_))); } diff --git a/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs b/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs index 5cf45b8ee..eb0dae66f 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs @@ -9,7 +9,6 @@ mod host_logic { const NAME: &'static str; type Response; fn into_message(self) -> RemoteMessage; - fn from_message(message: RemoteMessage) -> Option; fn response_into_message(response: Response) -> RemoteMessage; fn response_from_message( message: RemoteMessage, @@ -37,7 +36,7 @@ mod host_logic { pub mod messages { #[derive(Debug, Clone, PartialEq, Eq)] - pub struct FooRequest(pub u32); + pub struct Request(pub T); #[derive(Debug, Clone, PartialEq, Eq)] pub struct BarRequest; @@ -65,7 +64,7 @@ mod host_logic { #[derive(truapi_macros::SsoWire)] pub enum RemoteMessage { Disconnected, - FooRequest(Box), + FooRequest(Box>), FooResponse(Response), BarRequest(BarRequest), BarResponse(Response), From 675124528a8854cbd7699defccaf009bcada3a54 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 08:47:04 +0000 Subject: [PATCH 5/8] fix(macros): support raw identifiers in SSO handlers --- rust/crates/truapi-macros/src/sso_service.rs | 3 ++- rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs | 2 +- rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs | 4 ++-- rust/crates/truapi-macros/tests/ui/sso/support/wire.rs | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/rust/crates/truapi-macros/src/sso_service.rs b/rust/crates/truapi-macros/src/sso_service.rs index ac56561fb..54cab424d 100644 --- a/rust/crates/truapi-macros/src/sso_service.rs +++ b/rust/crates/truapi-macros/src/sso_service.rs @@ -2,6 +2,7 @@ use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; +use syn::ext::IdentExt; use syn::{FnArg, ImplItem, ItemImpl, Pat, Signature, Type, parse_macro_input}; use crate::sso_common::{enum_path, wire_path}; @@ -72,7 +73,7 @@ fn expand_sso_service(mut item: ItemImpl) -> syn::Result { }); let response_variant = last_segment(&response_ty)?; let name = &method.sig.ident; - let method_name = name.to_string(); + let method_name = name.unraw().to_string(); let stem: String = method_name .split('_') .map(|word| { diff --git a/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs index ed1cfcf0e..21b4adefb 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs @@ -18,7 +18,7 @@ impl Service { #[truapi_macros::sso_service] impl Service { - async fn foo(&self, _: &SsoRequestContext, request: Request) -> FooResponse { + async fn r#foo(&self, _: &SsoRequestContext, request: Request) -> FooResponse { let value = self.value(request).await?; if value == 0 { return Err("zero".into()); diff --git a/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs b/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs index ef53a3748..b6cad7723 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs @@ -7,7 +7,7 @@ mod runtime { pub mod sso_service { use crate::host_logic::sso::messages::{Response, v1}; - use crate::host_logic::sso::wire::{ResponseOutcome, SsoError}; + use crate::host_logic::sso::wire::ResponseOutcome; pub struct SsoRequestContext; @@ -46,7 +46,7 @@ mod runtime { } } - impl SsoReply> { + impl SsoReply> { pub fn finish( self, id: &str, diff --git a/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs b/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs index eb0dae66f..a35ea94e1 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs @@ -15,7 +15,7 @@ mod host_logic { ) -> Option>; } - pub trait SsoError { + pub trait SsoError: core::fmt::Display { fn not_connected() -> Self; } From f8f27ebd5e7c36d886afd0b913bc80ce5a78a6c5 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 09:01:18 +0000 Subject: [PATCH 6/8] refactor(macros): accept Rust parameter patterns and inline private helpers --- rust/crates/truapi-macros/src/lib.rs | 1 - rust/crates/truapi-macros/src/sso_common.rs | 45 ------------------- rust/crates/truapi-macros/src/sso_service.rs | 17 ++----- rust/crates/truapi-macros/src/sso_wire.rs | 33 +++++++++++++- .../tests/ui/sso/pass/handlers.rs | 10 ++--- 5 files changed, 40 insertions(+), 66 deletions(-) delete mode 100644 rust/crates/truapi-macros/src/sso_common.rs diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index 6b0973e48..22e33e14b 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -4,7 +4,6 @@ //! public proc-macro entry points to be defined at the crate root. mod service; -mod sso_common; mod sso_service; mod sso_wire; mod versioned_type; diff --git a/rust/crates/truapi-macros/src/sso_common.rs b/rust/crates/truapi-macros/src/sso_common.rs deleted file mode 100644 index 1ef475848..000000000 --- a/rust/crates/truapi-macros/src/sso_common.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Shared names and server paths used by the SSO macros. - -use proc_macro2::TokenStream; -use quote::quote; - -/// Request contract implemented by the service macro. -pub(super) fn wire_path() -> TokenStream { - quote!(crate::host_logic::sso::wire) -} - -/// Hand-written wire enum shared by SSO requests and responses. -pub(super) fn enum_path() -> TokenStream { - quote!(crate::host_logic::sso::messages::v1::RemoteMessage) -} - -/// Convert a request variant stem to its handler and client action name. -pub(super) 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/sso_service.rs b/rust/crates/truapi-macros/src/sso_service.rs index 54cab424d..afb42543a 100644 --- a/rust/crates/truapi-macros/src/sso_service.rs +++ b/rust/crates/truapi-macros/src/sso_service.rs @@ -3,9 +3,7 @@ use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; use syn::ext::IdentExt; -use syn::{FnArg, ImplItem, ItemImpl, Pat, Signature, Type, parse_macro_input}; - -use crate::sso_common::{enum_path, wire_path}; +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( @@ -34,10 +32,6 @@ pub(super) fn expand( } } -fn reply_path() -> TokenStream { - quote!(crate::runtime::sso_service::SsoReply) -} - /// Pair and dispatch the handlers in one inherent implementation. fn expand_sso_service(mut item: ItemImpl) -> syn::Result { if item.trait_.is_some() { @@ -52,10 +46,10 @@ fn expand_sso_service(mut item: ItemImpl) -> syn::Result { "SSO handlers require a concrete service type", )); } - let wire = wire_path(); - let message = enum_path(); - let reply = reply_path(); + 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 { @@ -195,9 +189,6 @@ fn method_types(sig: &Signature) -> syn::Result<(Type, Type)> { { return Err(shape_error()); } - let Pat::Ident(_) = request.pat.as_ref() else { - return Err(shape_error()); - }; let syn::ReturnType::Type(_, output) = &sig.output else { return Err(shape_error()); }; diff --git a/rust/crates/truapi-macros/src/sso_wire.rs b/rust/crates/truapi-macros/src/sso_wire.rs index f85d02ef9..11c223210 100644 --- a/rust/crates/truapi-macros/src/sso_wire.rs +++ b/rust/crates/truapi-macros/src/sso_wire.rs @@ -6,8 +6,6 @@ use syn::{ Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, Variant, parse_macro_input, }; -use crate::sso_common::snake_case; - const DISCONNECT_VARIANT: &str = "Disconnected"; /// Parse the macro input and emit generated code or a compiler diagnostic. @@ -215,3 +213,34 @@ fn box_inner(ty: &Type) -> Option<&Type> { _ => 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/tests/ui/sso/pass/handlers.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs index 21b4adefb..a6e62ad2a 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs @@ -11,22 +11,22 @@ impl Service { Self } - async fn value(&self, request: Request) -> Result { - Ok(request.0) + async fn value(&self, value: u32) -> Result { + Ok(value) } } #[truapi_macros::sso_service] impl Service { - async fn r#foo(&self, _: &SsoRequestContext, request: Request) -> FooResponse { - let value = self.value(request).await?; + async fn r#foo(&self, _: &SsoRequestContext, Request(value): Request) -> FooResponse { + let value = self.value(value).await?; if value == 0 { return Err("zero".into()); } Ok(value) } - async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> BarResponse { + async fn bar(&self, _: &SsoRequestContext, _: BarRequest) -> BarResponse { SsoReply::::from(Ok(2)).with_outcome(ResponseOutcome) } } From 563110f0b58b1ee5d463ee41dd73ae88081f9820 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 09:13:15 +0000 Subject: [PATCH 7/8] test(macros): consolidate SSO fixtures and remove mock runtime behavior --- .../tests/ui/sso/pass/explicit_pairing.rs | 29 ------------------- .../tests/ui/sso/pass/handlers.rs | 19 +++++++++--- .../tests/ui/sso/pass/shared_response.rs | 3 -- .../tests/ui/sso/pass/wire_without_service.rs | 26 ++++++++++++++--- .../tests/ui/sso/support/runtime.rs | 26 ++++------------- .../tests/ui/sso/support/wire.rs | 6 ---- 6 files changed, 43 insertions(+), 66 deletions(-) delete mode 100644 rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs diff --git a/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs deleted file mode 100644 index cb1eb44c7..000000000 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/explicit_pairing.rs +++ /dev/null @@ -1,29 +0,0 @@ -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) -> BarResponse { - Ok(1) - } - - async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> FooResponse { - Ok(2) - } -} - -fn main() { - fn check>() {} - check::>(); - 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()); -} diff --git a/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs index a6e62ad2a..35368002f 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/handlers.rs @@ -1,7 +1,10 @@ include!("../support/wire.rs"); include!("../support/runtime.rs"); -use host_logic::sso::{messages::*, wire::ResponseOutcome}; +use host_logic::sso::{ + messages::*, + wire::{ResponseOutcome, SsoRequest}, +}; use runtime::sso_service::{SsoReply, SsoRequestContext}; struct Service; @@ -18,7 +21,7 @@ impl Service { #[truapi_macros::sso_service] impl Service { - async fn r#foo(&self, _: &SsoRequestContext, Request(value): Request) -> FooResponse { + async fn r#foo(&self, _: &SsoRequestContext, Request(value): Request) -> BarResponse { let value = self.value(value).await?; if value == 0 { return Err("zero".into()); @@ -26,12 +29,20 @@ impl Service { Ok(value) } - async fn bar(&self, _: &SsoRequestContext, _: BarRequest) -> BarResponse { - SsoReply::::from(Ok(2)).with_outcome(ResponseOutcome) + 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( 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 index 4a8038708..2673d02f0 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/pass/shared_response.rs @@ -18,9 +18,6 @@ impl Service { } fn main() { - fn check>() {} - check::>(); - check::(); let response = BarRequest::response_into_message(Response { responding_to: "m-1".into(), payload: Ok(7), 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 index d595eecf5..4c242868c 100644 --- 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 @@ -1,8 +1,26 @@ include!("../support/wire.rs"); fn main() { - use host_logic::sso::messages::{Request, v1}; - let request = v1::RemoteMessage::FooRequest(Box::new(Request(1))); - assert_eq!(request.name(), "foo"); - assert!(matches!(v1::classify(request), v1::Incoming::Request(_))); + 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 index b6cad7723..ea02237f2 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/support/runtime.rs @@ -1,4 +1,4 @@ -// Context and reply operations consumed by generated dispatch. +// Compile-time contracts consumed by generated dispatch; runtime behavior is tested in the server. #[allow(dead_code)] mod runtime { pub mod authority { @@ -25,23 +25,16 @@ mod runtime { pub struct Answer; - pub struct SsoReply

{ - payload: P, - outcome: Option, - } + pub struct SsoReply

(P); impl

From

for SsoReply

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

SsoReply

{ - pub fn with_outcome(mut self, outcome: ResponseOutcome) -> Self { - self.outcome = Some(outcome); + pub fn with_outcome(self, _: ResponseOutcome) -> Self { self } } @@ -49,16 +42,9 @@ mod runtime { impl SsoReply> { pub fn finish( self, - id: &str, - wrap: impl FnOnce(Response>) -> v1::RemoteMessage, + _: &str, + _: impl FnOnce(Response>) -> v1::RemoteMessage, ) -> Answer { - let _outcome = self - .outcome - .unwrap_or_else(|| ResponseOutcome::from_payload(&self.payload)); - let _message = wrap(Response { - responding_to: id.to_string(), - payload: self.payload, - }); 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 index a35ea94e1..4d7f522f1 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs +++ b/rust/crates/truapi-macros/tests/ui/sso/support/wire.rs @@ -26,12 +26,6 @@ mod host_logic { } pub struct ResponseOutcome; - - impl ResponseOutcome { - pub fn from_payload(_: &Result) -> Self { - Self - } - } } pub mod messages { From c02ca72148683c04b72e38b751ae746be4b177e2 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 13:29:32 +0000 Subject: [PATCH 8/8] test(macros): refresh SSO diagnostics for CI Rust toolchain --- .../tests/ui/sso/fail/request_without_variant.stderr | 6 +++--- .../tests/ui/sso/fail/response_without_variant.stderr | 6 +++--- .../truapi-macros/tests/ui/sso/fail/wrong_payload.stderr | 8 ++++++-- 3 files changed, 12 insertions(+), 8 deletions(-) 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 index e146472e8..7e10db4db 100644 --- 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 @@ -1,13 +1,13 @@ -error[E0599]: no variant or associated item named `BazRequest` found for enum `AnyRequest` in the current scope +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 or associated item not found in `AnyRequest` + | ^^^ variant, associated function, or constant not found in `AnyRequest` | ::: tests/ui/sso/fail/../support/wire.rs | | #[derive(truapi_macros::SsoWire)] - | ---------------------- variant or associated item `BazRequest` not found for this enum + | ---------------------- variant, associated function, or constant `BazRequest` not found for this enum | help: there is a variant with a similar name | 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 index c09dac388..a8914aa6d 100644 --- 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 @@ -1,10 +1,10 @@ -error[E0599]: no variant or associated item named `MissingResponse` found for enum `messages::v1::RemoteMessage` in the current scope +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 or associated item not found in `messages::v1::RemoteMessage` + | ^^^^^^^^^^^^^^^ variant, associated function, or constant not found in `messages::v1::RemoteMessage` | ::: tests/ui/sso/fail/../support/wire.rs | | pub enum RemoteMessage { - | ---------------------- variant or associated item `MissingResponse` not found for this enum + | ---------------------- variant, associated function, or constant `MissingResponse` not found for this enum 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 index 6191d2e5a..2d278f075 100644 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.stderr +++ b/rust/crates/truapi-macros/tests/ui/sso/fail/wrong_payload.stderr @@ -4,8 +4,12 @@ error[E0277]: the trait bound `SsoReply>: From>` is not implemented for `SsoReply>` - but trait `From>` is implemented for it +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)