use std::{
fmt,
sync::atomic::{AtomicU64, Ordering},
};
#[doc(hidden)]
pub use steam_user_impl::steam_endpoint;
::tokio::task_local! {
pub static CURRENT_ENDPOINT: &'static EndpointInfo;
}
pub fn current_endpoint() -> Option<&'static EndpointInfo> {
CURRENT_ENDPOINT.try_with(|ep| *ep).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
}
impl fmt::Display for HttpMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Delete => "DELETE",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Host {
Community,
Store,
Help,
Api,
ShortLink,
}
impl Host {
pub const fn hostname(self) -> &'static str {
match self {
Host::Community => "steamcommunity.com",
Host::Store => "store.steampowered.com",
Host::Help => "help.steampowered.com",
Host::Api => "api.steampowered.com",
Host::ShortLink => "s.team",
}
}
pub const fn base_url(self) -> &'static str {
match self {
Host::Community => "https://steamcommunity.com",
Host::Store => "https://store.steampowered.com",
Host::Help => "https://help.steampowered.com",
Host::Api => "https://api.steampowered.com",
Host::ShortLink => "https://s.team",
}
}
}
impl fmt::Display for Host {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.hostname())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EndpointKind {
Read,
Write,
Auth,
Upload,
Recovery,
}
impl fmt::Display for EndpointKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
EndpointKind::Read => "read",
EndpointKind::Write => "write",
EndpointKind::Auth => "auth",
EndpointKind::Upload => "upload",
EndpointKind::Recovery => "recovery",
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct EndpointInfo {
pub name: &'static str,
pub module: &'static str,
pub method: HttpMethod,
pub host: Host,
pub path: &'static str,
pub kind: EndpointKind,
}
::inventory::collect!(EndpointInfo);
#[derive(Debug)]
pub struct EndpointMetrics {
by_host_kind: [[AtomicU64; 5]; 5],
total: AtomicU64,
}
#[derive(Debug, Clone, Copy)]
pub struct EndpointMetricsSnapshot {
pub by_host_kind: [[u64; 5]; 5],
pub total: u64,
}
fn host_index(host: Host) -> usize {
match host {
Host::Community => 0,
Host::Store => 1,
Host::Help => 2,
Host::Api => 3,
Host::ShortLink => 4,
}
}
fn kind_index(kind: EndpointKind) -> usize {
match kind {
EndpointKind::Read => 0,
EndpointKind::Write => 1,
EndpointKind::Auth => 2,
EndpointKind::Upload => 3,
EndpointKind::Recovery => 4,
}
}
impl EndpointMetrics {
const fn new() -> Self {
Self {
by_host_kind: [const { [const { AtomicU64::new(0) }; 5] }; 5],
total: AtomicU64::new(0),
}
}
pub fn record_call(&self, ep: &EndpointInfo) {
self.by_host_kind[host_index(ep.host)][kind_index(ep.kind)].fetch_add(1, Ordering::Relaxed);
self.total.fetch_add(1, Ordering::Relaxed);
}
pub fn snapshot(&self) -> EndpointMetricsSnapshot {
let mut by_host_kind = [[0u64; 5]; 5];
for (h, row) in self.by_host_kind.iter().enumerate() {
for (k, slot) in row.iter().enumerate() {
by_host_kind[h][k] = slot.load(Ordering::Relaxed);
}
}
EndpointMetricsSnapshot { by_host_kind, total: self.total.load(Ordering::Relaxed) }
}
pub fn reset(&self) {
for row in &self.by_host_kind {
for slot in row {
slot.store(0, Ordering::Relaxed);
}
}
self.total.store(0, Ordering::Relaxed);
}
}
impl EndpointMetricsSnapshot {
pub fn count(&self, host: Host, kind: EndpointKind) -> u64 {
self.by_host_kind[host_index(host)][kind_index(kind)]
}
pub fn count_by_host(&self, host: Host) -> u64 {
self.by_host_kind[host_index(host)].iter().sum()
}
pub fn count_by_kind(&self, kind: EndpointKind) -> u64 {
self.by_host_kind.iter().map(|row| row[kind_index(kind)]).sum()
}
}
static METRICS: std::sync::LazyLock<EndpointMetrics> = std::sync::LazyLock::new(EndpointMetrics::new);
pub fn metrics() -> &'static EndpointMetrics {
&METRICS
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
fn registry() -> Vec<&'static EndpointInfo> {
::inventory::iter::<EndpointInfo>().collect()
}
#[test]
fn registry_has_full_entries() {
let endpoints = registry();
assert!(
endpoints.len() >= 130,
"registry shrunk: {} endpoints — expected ~144, did the macro stop firing?",
endpoints.len(),
);
assert!(
endpoints.len() <= 200,
"registry grew unexpectedly: {} endpoints — duplicate registration?",
endpoints.len(),
);
}
#[test]
fn no_duplicate_endpoints() {
let mut seen: HashSet<(&str, &str)> = HashSet::new();
for ep in registry() {
let key = (ep.module, ep.name);
assert!(seen.insert(key), "duplicate endpoint: {}::{}", ep.module, ep.name);
}
}
#[test]
fn get_notifications_metadata() {
let ep = registry()
.into_iter()
.find(|e| e.name == "get_notifications")
.expect("get_notifications must be registered");
assert_eq!(ep.method, HttpMethod::Get);
assert_eq!(ep.host, Host::Community);
assert_eq!(ep.path, "/actions/GetNotificationCounts");
assert_eq!(ep.kind, EndpointKind::Read);
}
#[test]
fn get_player_reports_metadata() {
let ep = registry()
.into_iter()
.find(|e| e.name == "get_player_reports")
.expect("get_player_reports must be registered");
assert_eq!(ep.method, HttpMethod::Get);
assert_eq!(ep.host, Host::Community);
assert_eq!(ep.path, "/my/reports/");
assert_eq!(ep.kind, EndpointKind::Read);
}
#[test]
fn host_hostname_strings() {
assert_eq!(Host::Community.hostname(), "steamcommunity.com");
assert_eq!(Host::Store.hostname(), "store.steampowered.com");
assert_eq!(Host::Help.hostname(), "help.steampowered.com");
assert_eq!(Host::Api.hostname(), "api.steampowered.com");
}
#[test]
fn host_base_url_strings() {
assert_eq!(Host::Community.base_url(), "https://steamcommunity.com");
assert_eq!(Host::Store.base_url(), "https://store.steampowered.com");
assert_eq!(Host::Help.base_url(), "https://help.steampowered.com");
assert_eq!(Host::Api.base_url(), "https://api.steampowered.com");
}
#[test]
fn metrics_record_increments_correct_slots() {
let m = EndpointMetrics::new();
let ep_read = EndpointInfo {
name: "x", module: "test", method: HttpMethod::Get,
host: Host::Community, path: "/x", kind: EndpointKind::Read,
};
let ep_recovery = EndpointInfo {
name: "y", module: "test", method: HttpMethod::Post,
host: Host::Help, path: "/y", kind: EndpointKind::Recovery,
};
m.record_call(&ep_read);
m.record_call(&ep_read);
m.record_call(&ep_recovery);
let snap = m.snapshot();
assert_eq!(snap.total, 3);
assert_eq!(snap.count(Host::Community, EndpointKind::Read), 2);
assert_eq!(snap.count(Host::Help, EndpointKind::Recovery), 1);
assert_eq!(snap.count(Host::Community, EndpointKind::Write), 0);
assert_eq!(snap.count_by_host(Host::Community), 2);
assert_eq!(snap.count_by_kind(EndpointKind::Read), 2);
assert_eq!(snap.count_by_kind(EndpointKind::Recovery), 1);
}
#[tokio::test]
async fn task_local_propagates_endpoint() {
assert!(current_endpoint().is_none());
static EP: EndpointInfo = EndpointInfo {
name: "demo", module: "test", method: HttpMethod::Get,
host: Host::Community, path: "/demo", kind: EndpointKind::Read,
};
CURRENT_ENDPOINT
.scope(&EP, async move {
let inner = current_endpoint().expect("set inside scope");
assert_eq!(inner.name, "demo");
assert_eq!(inner.host, Host::Community);
})
.await;
assert!(current_endpoint().is_none());
}
}