Skip to main content

steam_user_impl/
lib.rs

1//! Procedural macros for the `steam-user` crate.
2//!
3//! Provides `#[steam_endpoint(METHOD, host = ..., path = ..., kind = ...)]`,
4//! which annotates a method that issues an HTTP call to a Steam network
5//! endpoint. The macro:
6//!
7//! 1. Wraps the function with `#[tracing::instrument]` so every call carries
8//!    structured fields (`steam.endpoint.method/host/path/kind` and
9//!    `steam.module`). Args are skipped via `skip(...)` to avoid logging
10//!    secrets — `self` plus any param whose name is in [`SENSITIVE_PARAMS`].
11//! 2. Emits an `inventory::submit!` entry so all annotated endpoints are
12//!    discoverable at runtime via `inventory::iter::<EndpointInfo>()`.
13//!
14//! The macro generates paths under `crate::endpoint::*`, so it is intended to
15//! be used from inside the `steam-user` crate. Generated paths also assume
16//! `inventory` and `tracing` are direct dependencies of the using crate.
17
18use proc_macro::TokenStream;
19use quote::quote;
20use syn::{
21    parse::{Parse, ParseStream},
22    parse_macro_input, FnArg, Ident, ItemFn, LitStr, Pat, Token,
23};
24
25/// Parameter names whose values must never be logged.
26///
27/// Matched by exact identifier on the function signature. The macro adds any
28/// matching params to the `tracing::instrument(skip(...))` list.
29const SENSITIVE_PARAMS: &[&str] = &[
30    "identity_secret",
31    "shared_secret",
32    "password",
33    "wallet_code",
34    "code",
35    "pin",
36    "revocation_code",
37    "access_token",
38    "refresh_token",
39    "api_key",
40    "auth_payload",
41    "secret",
42    "totp_code",
43    "activation_code",
44    "two_factor_code",
45];
46
47struct EndpointAttr {
48    method: Ident,
49    host: Ident,
50    path: LitStr,
51    kind: Ident,
52}
53
54impl Parse for EndpointAttr {
55    fn parse(input: ParseStream) -> syn::Result<Self> {
56        let method: Ident = input.parse()?;
57        input.parse::<Token![,]>()?;
58
59        let mut host: Option<Ident> = None;
60        let mut path: Option<LitStr> = None;
61        let mut kind: Option<Ident> = None;
62
63        while !input.is_empty() {
64            let key: Ident = input.parse()?;
65            input.parse::<Token![=]>()?;
66            match key.to_string().as_str() {
67                "host" => host = Some(input.parse()?),
68                "path" => path = Some(input.parse()?),
69                "kind" => kind = Some(input.parse()?),
70                _ => return Err(syn::Error::new_spanned(key, "expected `host`, `path`, or `kind`")),
71            }
72            if !input.is_empty() {
73                input.parse::<Token![,]>()?;
74            }
75        }
76
77        Ok(EndpointAttr {
78            method,
79            host: host.ok_or_else(|| syn::Error::new(input.span(), "missing `host = ...`"))?,
80            path: path.ok_or_else(|| syn::Error::new(input.span(), "missing `path = ...`"))?,
81            kind: kind.ok_or_else(|| syn::Error::new(input.span(), "missing `kind = ...`"))?,
82        })
83    }
84}
85
86/// Annotate a Steam HTTP endpoint method with structured metadata.
87///
88/// # Syntax
89///
90/// ```ignore
91/// #[steam_endpoint(GET, host = Community, path = "/actions/GetNotificationCounts", kind = Read)]
92/// pub async fn get_notifications(&self) -> Result<Notifications, SteamUserError> { ... }
93/// ```
94///
95/// - `METHOD`: bare ident — `GET`, `POST`, `PUT`, or `DELETE`.
96/// - `host`: bare ident — variant of `crate::endpoint::Host`.
97/// - `path`: string literal — URL path *template* (e.g. `/profiles/{steam_id}/edit`),
98///   not a resolved URL. Used as a low-cardinality label in metrics.
99/// - `kind`: bare ident — variant of `crate::endpoint::EndpointKind`
100///   (`Read`, `Write`, `Auth`, `Upload`, `Recovery`).
101#[proc_macro_attribute]
102pub fn steam_endpoint(attr: TokenStream, item: TokenStream) -> TokenStream {
103    let attr = parse_macro_input!(attr as EndpointAttr);
104    let mut func = parse_macro_input!(item as ItemFn);
105
106    let method_str = attr.method.to_string();
107    let method_variant_name = match method_str.as_str() {
108        "GET" => "Get",
109        "POST" => "Post",
110        "PUT" => "Put",
111        "DELETE" => "Delete",
112        _ => {
113            return syn::Error::new_spanned(&attr.method, "expected GET, POST, PUT, or DELETE")
114                .to_compile_error()
115                .into();
116        }
117    };
118    let method_variant = Ident::new(method_variant_name, attr.method.span());
119
120    let host_ident = attr.host.clone();
121    let kind_ident = attr.kind.clone();
122
123    let host_label = host_ident.to_string().to_lowercase();
124    let kind_label = kind_ident.to_string().to_lowercase();
125    let path_str = attr.path.value();
126    let method_label = method_str.clone();
127
128    let fn_name = func.sig.ident.clone();
129    let fn_name_str = fn_name.to_string();
130
131    // Build the skip list for tracing::instrument. Always skip `self` if the
132    // function has a receiver, plus any param whose ident matches a sensitive
133    // name. Tracing errors at compile time if we list a param that doesn't
134    // exist, so we only emit names that are actually present.
135    let has_receiver = func.sig.inputs.iter().any(|a| matches!(a, FnArg::Receiver(_)));
136    let mut skip_idents: Vec<Ident> = Vec::new();
137    if has_receiver {
138        skip_idents.push(Ident::new("self", proc_macro2::Span::call_site()));
139    }
140    for arg in &func.sig.inputs {
141        if let FnArg::Typed(pat_type) = arg {
142            if let Pat::Ident(pat_ident) = &*pat_type.pat {
143                if SENSITIVE_PARAMS.contains(&pat_ident.ident.to_string().as_str()) {
144                    skip_idents.push(pat_ident.ident.clone());
145                }
146            }
147        }
148    }
149
150    // `skip_all` is used so args we don't recognize (e.g. `impl AsRef<Path>`,
151    // user-defined types lacking `Debug`) never break compilation, and so
152    // secret values nested inside non-sensitive-named structs aren't
153    // accidentally logged. The `skip_idents` list is computed only as a
154    // diagnostic aid; `skip_all` supersedes it.
155    let _ = skip_idents;
156    let instrument: syn::Attribute = syn::parse_quote! {
157        #[::tracing::instrument(
158            name = #fn_name_str,
159            skip_all,
160            fields(
161                steam.endpoint.method = #method_label,
162                steam.endpoint.host = #host_label,
163                steam.endpoint.path = #path_str,
164                steam.endpoint.kind = #kind_label,
165                steam.module = ::core::module_path!(),
166            )
167        )]
168    };
169    func.attrs.insert(0, instrument);
170
171    // Wrap the function body in a `CURRENT_ENDPOINT.scope(...)` so that
172    // `client::SteamRequestBuilder::send()` can read the active endpoint's
173    // metadata via task-local storage. This is the bridge between
174    // compile-time annotation and runtime behaviour (per-host rate limiting,
175    // kind-aware retry, metrics).
176    //
177    // The static `__EP` is a per-method `EndpointInfo` value with `'static`
178    // lifetime, separate from the inventory submission below — keeping them
179    // separate avoids having to make the inventory const indirectly
180    // referenceable from inside the function body.
181    let original_block = func.block.clone();
182    let new_block: syn::Block = syn::parse_quote! {
183        {
184            static __EP: crate::endpoint::EndpointInfo = crate::endpoint::EndpointInfo {
185                name: #fn_name_str,
186                module: ::core::module_path!(),
187                method: crate::endpoint::HttpMethod::#method_variant,
188                host: crate::endpoint::Host::#host_ident,
189                path: #path_str,
190                kind: crate::endpoint::EndpointKind::#kind_ident,
191            };
192            crate::endpoint::CURRENT_ENDPOINT
193                .scope(&__EP, async move #original_block)
194                .await
195        }
196    };
197    *func.block = new_block;
198
199    // Wrap `inventory::submit!` (which expands to an anonymous `const _`) in a
200    // *named* associated const so the macro can be applied to methods inside
201    // an `impl` block. Inside an impl, only named associated items are
202    // permitted; the named-const wrapper makes the registration valid in both
203    // module-level and impl-level positions.
204    let const_name = Ident::new(
205        &format!("__STEAM_ENDPOINT_INFO_{}", fn_name_str.to_uppercase()),
206        fn_name.span(),
207    );
208
209    let submit = quote! {
210        #[doc(hidden)]
211        #[allow(non_upper_case_globals, dead_code)]
212        const #const_name: () = {
213            ::inventory::submit! {
214                crate::endpoint::EndpointInfo {
215                    name: #fn_name_str,
216                    module: ::core::module_path!(),
217                    method: crate::endpoint::HttpMethod::#method_variant,
218                    host: crate::endpoint::Host::#host_ident,
219                    path: #path_str,
220                    kind: crate::endpoint::EndpointKind::#kind_ident,
221                }
222            }
223        };
224    };
225
226    let output = quote! {
227        #func
228        #submit
229    };
230
231    output.into()
232}