steam-user-impl 0.1.0

Internal proc-macros for the `steam-user` crate. Do not depend on this crate directly — use `steam-user`; this crate's API is unstable and may change without notice.
Documentation
//! Procedural macros for the `steam-user` crate.
//!
//! Provides `#[steam_endpoint(METHOD, host = ..., path = ..., kind = ...)]`,
//! which annotates a method that issues an HTTP call to a Steam network
//! endpoint. The macro:
//!
//! 1. Wraps the function with `#[tracing::instrument]` so every call carries
//!    structured fields (`steam.endpoint.method/host/path/kind` and
//!    `steam.module`). Args are skipped via `skip(...)` to avoid logging
//!    secrets — `self` plus any param whose name is in [`SENSITIVE_PARAMS`].
//! 2. Emits an `inventory::submit!` entry so all annotated endpoints are
//!    discoverable at runtime via `inventory::iter::<EndpointInfo>()`.
//!
//! The macro generates paths under `crate::endpoint::*`, so it is intended to
//! be used from inside the `steam-user` crate. Generated paths also assume
//! `inventory` and `tracing` are direct dependencies of the using crate.

use proc_macro::TokenStream;
use quote::quote;
use syn::{
    parse::{Parse, ParseStream},
    parse_macro_input, FnArg, Ident, ItemFn, LitStr, Pat, Token,
};

/// Parameter names whose values must never be logged.
///
/// Matched by exact identifier on the function signature. The macro adds any
/// matching params to the `tracing::instrument(skip(...))` list.
const SENSITIVE_PARAMS: &[&str] = &[
    "identity_secret",
    "shared_secret",
    "password",
    "wallet_code",
    "code",
    "pin",
    "revocation_code",
    "access_token",
    "refresh_token",
    "api_key",
    "auth_payload",
    "secret",
    "totp_code",
    "activation_code",
    "two_factor_code",
];

struct EndpointAttr {
    method: Ident,
    host: Ident,
    path: LitStr,
    kind: Ident,
}

impl Parse for EndpointAttr {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let method: Ident = input.parse()?;
        input.parse::<Token![,]>()?;

        let mut host: Option<Ident> = None;
        let mut path: Option<LitStr> = None;
        let mut kind: Option<Ident> = None;

        while !input.is_empty() {
            let key: Ident = input.parse()?;
            input.parse::<Token![=]>()?;
            match key.to_string().as_str() {
                "host" => host = Some(input.parse()?),
                "path" => path = Some(input.parse()?),
                "kind" => kind = Some(input.parse()?),
                _ => return Err(syn::Error::new_spanned(key, "expected `host`, `path`, or `kind`")),
            }
            if !input.is_empty() {
                input.parse::<Token![,]>()?;
            }
        }

        Ok(EndpointAttr {
            method,
            host: host.ok_or_else(|| syn::Error::new(input.span(), "missing `host = ...`"))?,
            path: path.ok_or_else(|| syn::Error::new(input.span(), "missing `path = ...`"))?,
            kind: kind.ok_or_else(|| syn::Error::new(input.span(), "missing `kind = ...`"))?,
        })
    }
}

/// Annotate a Steam HTTP endpoint method with structured metadata.
///
/// # Syntax
///
/// ```ignore
/// #[steam_endpoint(GET, host = Community, path = "/actions/GetNotificationCounts", kind = Read)]
/// pub async fn get_notifications(&self) -> Result<Notifications, SteamUserError> { ... }
/// ```
///
/// - `METHOD`: bare ident — `GET`, `POST`, `PUT`, or `DELETE`.
/// - `host`: bare ident — variant of `crate::endpoint::Host`.
/// - `path`: string literal — URL path *template* (e.g. `/profiles/{steam_id}/edit`),
///   not a resolved URL. Used as a low-cardinality label in metrics.
/// - `kind`: bare ident — variant of `crate::endpoint::EndpointKind`
///   (`Read`, `Write`, `Auth`, `Upload`, `Recovery`).
#[proc_macro_attribute]
pub fn steam_endpoint(attr: TokenStream, item: TokenStream) -> TokenStream {
    let attr = parse_macro_input!(attr as EndpointAttr);
    let mut func = parse_macro_input!(item as ItemFn);

    let method_str = attr.method.to_string();
    let method_variant_name = match method_str.as_str() {
        "GET" => "Get",
        "POST" => "Post",
        "PUT" => "Put",
        "DELETE" => "Delete",
        _ => {
            return syn::Error::new_spanned(&attr.method, "expected GET, POST, PUT, or DELETE")
                .to_compile_error()
                .into();
        }
    };
    let method_variant = Ident::new(method_variant_name, attr.method.span());

    let host_ident = attr.host.clone();
    let kind_ident = attr.kind.clone();

    let host_label = host_ident.to_string().to_lowercase();
    let kind_label = kind_ident.to_string().to_lowercase();
    let path_str = attr.path.value();
    let method_label = method_str.clone();

    let fn_name = func.sig.ident.clone();
    let fn_name_str = fn_name.to_string();

    // Build the skip list for tracing::instrument. Always skip `self` if the
    // function has a receiver, plus any param whose ident matches a sensitive
    // name. Tracing errors at compile time if we list a param that doesn't
    // exist, so we only emit names that are actually present.
    let has_receiver = func.sig.inputs.iter().any(|a| matches!(a, FnArg::Receiver(_)));
    let mut skip_idents: Vec<Ident> = Vec::new();
    if has_receiver {
        skip_idents.push(Ident::new("self", proc_macro2::Span::call_site()));
    }
    for arg in &func.sig.inputs {
        if let FnArg::Typed(pat_type) = arg {
            if let Pat::Ident(pat_ident) = &*pat_type.pat {
                if SENSITIVE_PARAMS.contains(&pat_ident.ident.to_string().as_str()) {
                    skip_idents.push(pat_ident.ident.clone());
                }
            }
        }
    }

    // `skip_all` is used so args we don't recognize (e.g. `impl AsRef<Path>`,
    // user-defined types lacking `Debug`) never break compilation, and so
    // secret values nested inside non-sensitive-named structs aren't
    // accidentally logged. The `skip_idents` list is computed only as a
    // diagnostic aid; `skip_all` supersedes it.
    let _ = skip_idents;
    let instrument: syn::Attribute = syn::parse_quote! {
        #[::tracing::instrument(
            name = #fn_name_str,
            skip_all,
            fields(
                steam.endpoint.method = #method_label,
                steam.endpoint.host = #host_label,
                steam.endpoint.path = #path_str,
                steam.endpoint.kind = #kind_label,
                steam.module = ::core::module_path!(),
            )
        )]
    };
    func.attrs.insert(0, instrument);

    // Wrap the function body in a `CURRENT_ENDPOINT.scope(...)` so that
    // `client::SteamRequestBuilder::send()` can read the active endpoint's
    // metadata via task-local storage. This is the bridge between
    // compile-time annotation and runtime behaviour (per-host rate limiting,
    // kind-aware retry, metrics).
    //
    // The static `__EP` is a per-method `EndpointInfo` value with `'static`
    // lifetime, separate from the inventory submission below — keeping them
    // separate avoids having to make the inventory const indirectly
    // referenceable from inside the function body.
    let original_block = func.block.clone();
    let new_block: syn::Block = syn::parse_quote! {
        {
            static __EP: crate::endpoint::EndpointInfo = crate::endpoint::EndpointInfo {
                name: #fn_name_str,
                module: ::core::module_path!(),
                method: crate::endpoint::HttpMethod::#method_variant,
                host: crate::endpoint::Host::#host_ident,
                path: #path_str,
                kind: crate::endpoint::EndpointKind::#kind_ident,
            };
            crate::endpoint::CURRENT_ENDPOINT
                .scope(&__EP, async move #original_block)
                .await
        }
    };
    *func.block = new_block;

    // Wrap `inventory::submit!` (which expands to an anonymous `const _`) in a
    // *named* associated const so the macro can be applied to methods inside
    // an `impl` block. Inside an impl, only named associated items are
    // permitted; the named-const wrapper makes the registration valid in both
    // module-level and impl-level positions.
    let const_name = Ident::new(
        &format!("__STEAM_ENDPOINT_INFO_{}", fn_name_str.to_uppercase()),
        fn_name.span(),
    );

    let submit = quote! {
        #[doc(hidden)]
        #[allow(non_upper_case_globals, dead_code)]
        const #const_name: () = {
            ::inventory::submit! {
                crate::endpoint::EndpointInfo {
                    name: #fn_name_str,
                    module: ::core::module_path!(),
                    method: crate::endpoint::HttpMethod::#method_variant,
                    host: crate::endpoint::Host::#host_ident,
                    path: #path_str,
                    kind: crate::endpoint::EndpointKind::#kind_ident,
                }
            }
        };
    };

    let output = quote! {
        #func
        #submit
    };

    output.into()
}