use std::time::Duration;
use reqwest::Client;
use serde::Deserialize;
use super::error::GasError;
use crate::error::SteamUserError;
const ENV_GAS_ENDPOINT: &str = "STEAM_GAS_ENDPOINT";
const DEFAULT_MAX_RETRIES: usize = 3;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
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()
})
}
#[derive(Debug, Deserialize)]
pub(crate) struct GasResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct GasCredentials {
pub cookie: Option<String>,
pub session_id: Option<String>,
pub access_token: Option<String>,
}
#[derive(Clone)]
pub struct GasSteamUser {
http: Client,
pub endpoint_url: String,
credentials: GasCredentials,
max_retries: usize,
}
impl Default for GasSteamUser {
fn default() -> Self {
Self::from_env().expect("STEAM_GAS_ENDPOINT env var must be set to use GasSteamUser::default()")
}
}
impl GasSteamUser {
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,
}
}
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))
}
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 }
}
pub fn set_max_retries(&mut self, max: usize) {
self.max_retries = max;
}
pub fn set_cookie(&mut self, cookie: String) {
self.credentials.cookie = Some(cookie);
}
pub fn set_session_id(&mut self, session_id: String) {
self.credentials.session_id = Some(session_id);
}
pub fn set_access_token(&mut self, token: String) {
self.credentials.access_token = Some(token);
}
pub fn set_credentials(&mut self, credentials: GasCredentials) {
self.credentials = credentials;
}
pub fn credentials(&self) -> &GasCredentials {
&self.credentials
}
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()));
for (k, v) in params {
query_params.push(((*k).into(), (*v).into()));
}
query_params
}
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
}
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 {
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");
}
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;
}
};
let status = resp.status().as_u16();
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;
}
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;
}
};
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);
}
match last_error {
Some(e) => Err(e),
None => Err(GasError::AllRetriesFailed),
}
}
pub async fn call_raw(&self, action: &str, params: &[(&str, &str)]) -> Result<serde_json::Value, GasError> {
self.call::<serde_json::Value>(action, params).await
}
pub async fn call_void(&self, action: &str, params: &[(&str, &str)]) -> Result<(), GasError> {
let _: serde_json::Value = self.call(action, params).await?;
Ok(())
}
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()
}
}