steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! GAS proxy client with generic action dispatch, retry logic, and credential
//! injection.

use std::time::Duration;

use reqwest::Client;
use serde::Deserialize;

use super::error::GasError;
use crate::error::SteamUserError;

/// Environment variable name for overriding the GAS endpoint URL at runtime.
const ENV_GAS_ENDPOINT: &str = "STEAM_GAS_ENDPOINT";

/// Default maximum number of retry attempts per request.
const DEFAULT_MAX_RETRIES: usize = 3;

/// Default HTTP request timeout (GAS cold starts can be slow).
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);

/// Default connect timeout for the HTTP client.
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);

/// Build a reqwest [`Client`] with the project-standard timeouts.
///
/// On failure (which is rare — only triggered by TLS backend init issues),
/// logs the error and returns a non-functional client built from
/// [`Client::default`] so callers do not panic. The non-functional client
/// will surface the underlying problem on first use.
fn build_http_client() -> Client {
    Client::builder().connect_timeout(DEFAULT_CONNECT_TIMEOUT).timeout(DEFAULT_TIMEOUT).build().unwrap_or_else(|e| {
        tracing::error!(error = %e, "GasSteamUser: failed to build reqwest client; using default client");
        Client::new()
    })
}

/// Generic response wrapper from the GAS endpoint.
#[derive(Debug, Deserialize)]
pub(crate) struct GasResponse<T> {
    pub success: bool,
    pub data: Option<T>,
    pub error: Option<String>,
}

/// Credentials passed to the GAS endpoint for authenticated requests.
///
/// The GAS router's `injectCredentials()` reads these from query params:
/// - `cookie`       → full Steam cookie string (e.g.
///   `steamLoginSecure=...;sessionid=...`)
/// - `sessionid`    → Steam session ID (auto-extracted from cookie if omitted)
/// - `accessToken`  → OAuth access token
#[derive(Debug, Clone, Default)]
pub struct GasCredentials {
    /// Full Steam cookie string.
    pub cookie: Option<String>,
    /// Steam session ID (if not embedded in the cookie).
    pub session_id: Option<String>,
    /// OAuth access token.
    pub access_token: Option<String>,
}

/// Client for proxying Steam API calls through a Google Apps Script endpoint.
///
/// Routes traffic through Google's infrastructure to avoid local IP rate
/// limits (HTTP 429 / connection resets).
///
/// Features automatic retry on transient network failures.
///
/// # Endpoint resolution
///
/// Supply the URL explicitly via [`new()`](Self::new) /
/// [`with_credentials()`](Self::with_credentials), or let
/// [`from_env()`](Self::from_env) read it from the `STEAM_GAS_ENDPOINT`
/// environment variable (required — no hardcoded fallback).
#[derive(Clone)]
pub struct GasSteamUser {
    /// Reusable HTTP client.
    http: Client,
    /// The GAS web-app endpoint URL.
    pub endpoint_url: String,
    /// Credentials injected into every authenticated request.
    credentials: GasCredentials,
    /// Maximum retry attempts per request.
    max_retries: usize,
}

impl Default for GasSteamUser {
    /// Panics if `STEAM_GAS_ENDPOINT` env var is not set.
    /// Use [`GasSteamUser::from_env`] for a fallible alternative, or
    /// [`GasSteamUser::new`] to supply the URL explicitly.
    fn default() -> Self {
        Self::from_env().expect("STEAM_GAS_ENDPOINT env var must be set to use GasSteamUser::default()")
    }
}

impl GasSteamUser {
    /// Create a new GAS client pointing to the given endpoint URL (no
    /// credentials).
    pub fn new(endpoint_url: &str) -> Self {
        let http = build_http_client();

        Self {
            http,
            endpoint_url: endpoint_url.to_string(),
            credentials: GasCredentials::default(),
            max_retries: DEFAULT_MAX_RETRIES,
        }
    }

    /// Create a new GAS client using the `STEAM_GAS_ENDPOINT` env var.
    ///
    /// Returns `Err` when the environment variable is not set.
    pub fn from_env() -> Result<Self, SteamUserError> {
        let url = std::env::var(ENV_GAS_ENDPOINT)
            .map_err(|_| SteamUserError::InvalidInput("STEAM_GAS_ENDPOINT not set".into()))?;
        Ok(Self::new(&url))
    }

    /// Create a new GAS client with credentials pre-configured.
    pub fn with_credentials(endpoint_url: &str, credentials: GasCredentials) -> Self {
        let http = build_http_client();

        Self { http, endpoint_url: endpoint_url.to_string(), credentials, max_retries: DEFAULT_MAX_RETRIES }
    }

    // ========================================================================
    // Configuration
    // ========================================================================

    /// Set the maximum number of retry attempts per request (default: 3).
    pub fn set_max_retries(&mut self, max: usize) {
        self.max_retries = max;
    }

    // ========================================================================
    // Credential setters
    // ========================================================================

    /// Set the full Steam cookie string.
    ///
    /// This is the most important credential — the GAS endpoint uses it to
    /// authenticate with Steam on your behalf.
    ///
    /// Example: `"steamLoginSecure=76561198012345678%7C%7Ctoken;
    /// sessionid=abc123"`
    pub fn set_cookie(&mut self, cookie: String) {
        self.credentials.cookie = Some(cookie);
    }

    /// Set the Steam session ID explicitly.
    ///
    /// If omitted, the GAS endpoint will auto-extract it from the cookie
    /// string.
    pub fn set_session_id(&mut self, session_id: String) {
        self.credentials.session_id = Some(session_id);
    }

    /// Set the OAuth access token.
    pub fn set_access_token(&mut self, token: String) {
        self.credentials.access_token = Some(token);
    }

    /// Replace all credentials at once.
    pub fn set_credentials(&mut self, credentials: GasCredentials) {
        self.credentials = credentials;
    }

    /// Returns a reference to the current credentials.
    pub fn credentials(&self) -> &GasCredentials {
        &self.credentials
    }

    // ========================================================================
    // Internal helpers
    // ========================================================================

    /// Build the non-secret query parameter list (action + user params only).
    ///
    /// Credentials (cookie, sessionid, accessToken) are sent in the POST body,
    /// not the URL, so they never appear in server logs or browser history.
    fn build_query_params(&self, action: &str, params: &[(&str, &str)]) -> Vec<(String, String)> {
        let mut query_params: Vec<(String, String)> = Vec::new();
        query_params.push(("action".into(), action.into()));

        // Append user-provided (non-secret) params
        for (k, v) in params {
            query_params.push(((*k).into(), (*v).into()));
        }

        query_params
    }

    /// Build the POST form body that carries credentials.
    fn build_credential_body(&self) -> Vec<(String, String)> {
        let mut body: Vec<(String, String)> = Vec::new();
        if let Some(ref cookie) = self.credentials.cookie {
            body.push(("cookie".into(), cookie.clone()));
        }
        if let Some(ref sid) = self.credentials.session_id {
            body.push(("sessionid".into(), sid.clone()));
        }
        if let Some(ref token) = self.credentials.access_token {
            body.push(("accessToken".into(), token.clone()));
        }
        body
    }

    /// Generic method to call any action on the GAS endpoint via POST.
    ///
    /// Non-secret parameters (action name, function arguments) go in the URL
    /// query string. Credentials (cookie, sessionid, accessToken) are sent in
    /// the POST body so they are never exposed in server logs or browser history.
    ///
    /// Retries transient failures (network errors, empty responses, JSON parse
    /// errors) up to `max_retries` times. API-level errors (success=false) are
    /// **not** retried since they indicate a logical error on the GAS side.
    pub(crate) async fn call<T: for<'de> Deserialize<'de>>(&self, action: &str, params: &[(&str, &str)]) -> Result<T, GasError> {
        let query_params = self.build_query_params(action, params);
        let query_refs: Vec<(&str, &str)> = query_params.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
        let cred_body = self.build_credential_body();
        let body_refs: Vec<(&str, &str)> = cred_body.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();

        let mut last_error: Option<GasError> = None;

        for attempt in 0..self.max_retries {
            if attempt > 0 {
                // Exponential backoff: 500ms, 1s, 2s, ...
                let delay = Duration::from_millis(500 * (1 << (attempt - 1)));
                tracing::debug!(action, attempt, delay_ms = delay.as_millis() as u64, "GAS retry");
                tokio::time::sleep(delay).await;
            } else {
                tracing::debug!(action, "GAS call");
            }

            // Send the request — action in query, credentials in POST body
            let resp = match self.http.post(&self.endpoint_url).query(&query_refs).form(&body_refs).send().await {
                Ok(r) => r,
                Err(e) => {
                    tracing::warn!(action, attempt, error = %e, "GAS request failed");
                    last_error = Some(GasError::Http(e));
                    continue;
                }
            };

            // Capture status before reading the body — `resp.text()` consumes
            // `resp`, so the HTTP status must be preserved separately to carry
            // typed cause information into `GasError::Api`.
            let status = resp.status().as_u16();

            // Read the body
            let text = match resp.text().await {
                Ok(t) => t,
                Err(e) => {
                    tracing::warn!(action, attempt, error = %e, "GAS failed to read response body");
                    last_error = Some(GasError::Http(e));
                    continue;
                }
            };

            if text.is_empty() {
                tracing::warn!(action, attempt, "GAS empty response");
                last_error = Some(GasError::Api { status, message: "Empty response".into() });
                continue;
            }

            // Parse JSON
            let gas_response: GasResponse<T> = match serde_json::from_str(&text) {
                Ok(r) => r,
                Err(e) => {
                    tracing::warn!(action, attempt, error = %e, "GAS failed to parse response");
                    last_error = Some(GasError::Json(e));
                    continue;
                }
            };

            // API-level errors are NOT retried — they are logical errors
            if !gas_response.success {
                return Err(GasError::Api { status, message: gas_response.error.unwrap_or_else(|| "Unknown GAS error".into()) });
            }

            return gas_response.data.ok_or(GasError::NoData);
        }

        // All retries exhausted
        match last_error {
            Some(e) => Err(e),
            None => Err(GasError::AllRetriesFailed),
        }
    }

    /// Call any action and return raw `serde_json::Value`.
    pub async fn call_raw(&self, action: &str, params: &[(&str, &str)]) -> Result<serde_json::Value, GasError> {
        self.call::<serde_json::Value>(action, params).await
    }

    /// Variant of [`call`] for void methods — discards the response data.
    pub async fn call_void(&self, action: &str, params: &[(&str, &str)]) -> Result<(), GasError> {
        let _: serde_json::Value = self.call(action, params).await?;
        Ok(())
    }

    /// Ping the endpoint to check connectivity.
    pub async fn ping(&self) -> Result<serde_json::Value, GasError> {
        self.call_raw("ping", &[]).await
    }
}

impl std::fmt::Debug for GasSteamUser {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GasSteamUser").field("endpoint_url", &self.endpoint_url).field("max_retries", &self.max_retries).field("has_cookie", &self.credentials.cookie.is_some()).field("has_session_id", &self.credentials.session_id.is_some()).field("has_access_token", &self.credentials.access_token.is_some()).finish()
    }
}