steam-user 0.1.6

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! Guard: every parameter a `RemoteSteamUser` service method declares must be
//! forwarded into its request body — i.e. each non-`self` param `p` must appear
//! as a `"p"` JSON key in the method body.
//!
//! This catches the "param accepted but silently dropped from the request"
//! class of bug: a method that takes `start: u32, count: u32` but sends
//! `json!({})` would default the values server-side without any compile error.
//! Compile-time signature checks (the shared `SteamUserApi` trait) catch
//! *signature drift* between backends; only this body check catches a param
//! that exists in the signature but never reaches the wire.
//!
//! Convention enforced: the JSON key equals the parameter name
//! (`get_my_listings(start, count)` → `json!({"start": start, "count": count})`).
//! A method that legitimately renames or omits a param (e.g. forwards it via the
//! URL path, or splits it into a locally-built value under the same key) can opt
//! out with a `// no-param-check` comment in the 8 lines above the `fn`.
//!
//! Failure mode:
//!
//! ```text
//! remote/services/market.rs:7 fn `get_my_listings` — param `count` not forwarded (no "count" key in body)
//! ```

use std::{fs, path::PathBuf};

use syn::{spanned::Spanned, FnArg, ImplItem, Item, Pat, Visibility};

/// Opt-out marker for methods that intentionally don't forward a param as a
/// same-named JSON key (path params, renamed keys, composites).
const SKIP_MARKER: &str = "no-param-check";

fn remote_services_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src").join("remote").join("services")
}

#[test]
fn every_remote_param_is_forwarded_in_body() {
    let dir = remote_services_dir();
    let entries = fs::read_dir(&dir).expect("remote services dir readable");

    let mut failures: Vec<String> = Vec::new();
    let mut files_checked = 0usize;
    let mut params_checked = 0usize;

    for entry in entries {
        let path = entry.expect("dir entry").path();
        if path.extension().and_then(|s| s.to_str()) != Some("rs") || !path.is_file() {
            continue;
        }

        files_checked += 1;
        let src = fs::read_to_string(&path).expect("read remote service file");
        let lines: Vec<&str> = src.lines().collect();
        let file_ast = syn::parse_file(&src).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()));
        let rel = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");

        for item in &file_ast.items {
            let Item::Impl(item_impl) = item else { continue };
            for impl_item in &item_impl.items {
                let ImplItem::Fn(fn_item) = impl_item else { continue };

                let is_pub = matches!(fn_item.vis, Visibility::Public(_));
                let is_async = fn_item.sig.asyncness.is_some();
                if !(is_pub && is_async) {
                    continue;
                }

                let fn_line_1based = fn_item.sig.fn_token.span().start().line;
                let fn_line_idx = fn_line_1based.saturating_sub(1);
                let look_back = fn_line_idx.saturating_sub(8)..fn_line_idx;
                let opted_out = lines[look_back].iter().any(|l| {
                    let t = l.trim_start();
                    t.starts_with("//") && t.contains(SKIP_MARKER)
                });
                if opted_out {
                    continue;
                }

                // Body text: from the block's opening to closing line.
                let body_start = fn_item.block.span().start().line.saturating_sub(1);
                let body_end = fn_item.block.span().end().line.min(lines.len());
                let body = lines[body_start..body_end].join("\n");

                for arg in &fn_item.sig.inputs {
                    // Skip the `&self` receiver.
                    let FnArg::Typed(pat_type) = arg else { continue };
                    let Pat::Ident(pat_ident) = pat_type.pat.as_ref() else { continue };
                    let name = pat_ident.ident.to_string();

                    params_checked += 1;

                    let key = format!("\"{name}\"");
                    if !body.contains(&key) {
                        failures.push(format!(
                            "remote/services/{rel}:{fn_line_1based} fn `{}` — param `{name}` not forwarded (no {key} key in body; add a `// {SKIP_MARKER}` comment if intentional)",
                            fn_item.sig.ident,
                        ));
                    }
                }
            }
        }
    }

    assert!(files_checked > 0, "no .rs files in {} — guard would silently pass", remote_services_dir().display());
    assert!(params_checked >= 50, "only {params_checked} remote params discovered — parser probably regressed");

    if !failures.is_empty() {
        let count = failures.len();
        let body = failures.join("\n  ");
        panic!("{count} remote param(s) not forwarded into the request body:\n  {body}");
    }
}