Skip to main content

steam_user/
endpoint.rs

1//! Endpoint metadata for Steam HTTP methods.
2//!
3//! Every public method in [`crate::services`] that issues an HTTP request to a
4//! Steam network endpoint is annotated with `#[steam_endpoint(...)]` (from the
5//! internal `steam-user-impl` crate, re-exported from this module). The annotation:
6//!
7//! 1. Wraps the function with a `tracing::instrument` span carrying
8//!    `steam.endpoint.method/host/path/kind` fields and `steam.module`.
9//! 2. Submits a static [`EndpointInfo`] entry to a global registry collected
10//!    by the `inventory` crate.
11//!
12//! Anywhere in the program can list every Steam endpoint at runtime:
13//!
14//! ```ignore
15//! for ep in inventory::iter::<steam_user::endpoint::EndpointInfo>() {
16//!     println!("{} {:?}{} ({})", ep.method, ep.host, ep.path, ep.kind);
17//! }
18//! ```
19//!
20//! # Path templates, not resolved URLs
21//!
22//! `path` is a *template* like `/profiles/{steam_id}/edit/settings`. It is
23//! used purely as a low-cardinality label for metrics and logs — it is not
24//! parsed or used to build the actual request. The full URL is constructed
25//! inside the function body by the existing client code.
26//!
27//! # `EndpointKind`
28//!
29//! Endpoints fall into five categories with very different operational
30//! characteristics:
31//!
32//! - [`Read`](EndpointKind::Read): idempotent GET. Safe to retry.
33//! - [`Write`](EndpointKind::Write): mutates server state. Retry only if the
34//!   protocol guarantees idempotency at the application layer.
35//! - [`Auth`](EndpointKind::Auth): login / 2FA. Steam IP-bans on spammy retry.
36//! - [`Upload`](EndpointKind::Upload): file or avatar upload. Use long
37//!   timeouts.
38//! - [`Recovery`](EndpointKind::Recovery): account-recovery wizard on
39//!   `help.steampowered.com`. **Locks the account on too many wrong attempts.**
40//!   Retry with extreme care.
41
42use std::{
43    fmt,
44    sync::atomic::{AtomicU64, Ordering},
45};
46
47// Re-exported from the internal `steam-user-impl` proc-macro crate so users
48// only need `steam-user` in their `Cargo.toml`. `#[doc(hidden)]` hides the
49// re-export path in rustdoc; consumers should refer to it via the macro's own
50// documentation rather than the `steam_user_impl::` path.
51#[doc(hidden)]
52pub use steam_user_impl::steam_endpoint;
53
54::tokio::task_local! {
55    /// Active endpoint for the running task.
56    ///
57    /// Set automatically by the `#[steam_endpoint(...)]` macro before the
58    /// function body executes. Read by `client::SteamRequestBuilder::send`
59    /// (and the kind-aware retry strategy) so HTTP-level behaviour can vary
60    /// based on the endpoint's host/kind without threading the metadata
61    /// through every helper.
62    ///
63    /// Use [`current_endpoint`] to read it; do not call `try_with` directly.
64    pub static CURRENT_ENDPOINT: &'static EndpointInfo;
65}
66
67/// Returns the active [`EndpointInfo`] for the running task, if any.
68///
69/// `None` when called outside an `#[steam_endpoint]`-annotated method (e.g.
70/// from `client::logged_in` or background helpers).
71pub fn current_endpoint() -> Option<&'static EndpointInfo> {
72    CURRENT_ENDPOINT.try_with(|ep| *ep).ok()
73}
74
75/// HTTP verb for a Steam endpoint.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub enum HttpMethod {
78    Get,
79    Post,
80    Put,
81    Delete,
82}
83
84impl fmt::Display for HttpMethod {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str(match self {
87            HttpMethod::Get => "GET",
88            HttpMethod::Post => "POST",
89            HttpMethod::Put => "PUT",
90            HttpMethod::Delete => "DELETE",
91        })
92    }
93}
94
95/// Steam network host that an endpoint targets.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub enum Host {
98    /// `steamcommunity.com` — community web pages and AJAX endpoints.
99    Community,
100    /// `store.steampowered.com` — store, account, and twofactor operations.
101    Store,
102    /// `help.steampowered.com` — recovery wizard, help requests.
103    Help,
104    /// `api.steampowered.com` — public WebAPI services.
105    Api,
106    /// `s.team` — Steam's URL shortener / short-link redirector. Issues a
107    /// 302 to a target on Community/Store. The cookie jar carries Steam
108    /// cookies through the redirect on the regular client.
109    ShortLink,
110}
111
112impl Host {
113    /// Returns the canonical hostname (without scheme).
114    pub const fn hostname(self) -> &'static str {
115        match self {
116            Host::Community => "steamcommunity.com",
117            Host::Store => "store.steampowered.com",
118            Host::Help => "help.steampowered.com",
119            Host::Api => "api.steampowered.com",
120            Host::ShortLink => "s.team",
121        }
122    }
123
124    /// Returns the HTTPS base URL (scheme + hostname, no trailing slash).
125    pub const fn base_url(self) -> &'static str {
126        match self {
127            Host::Community => "https://steamcommunity.com",
128            Host::Store => "https://store.steampowered.com",
129            Host::Help => "https://help.steampowered.com",
130            Host::Api => "https://api.steampowered.com",
131            Host::ShortLink => "https://s.team",
132        }
133    }
134}
135
136impl fmt::Display for Host {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.write_str(self.hostname())
139    }
140}
141
142/// Operational category of a Steam endpoint.
143///
144/// See the module-level docs for retry / rate-limit implications.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub enum EndpointKind {
147    Read,
148    Write,
149    Auth,
150    Upload,
151    Recovery,
152}
153
154impl fmt::Display for EndpointKind {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.write_str(match self {
157            EndpointKind::Read => "read",
158            EndpointKind::Write => "write",
159            EndpointKind::Auth => "auth",
160            EndpointKind::Upload => "upload",
161            EndpointKind::Recovery => "recovery",
162        })
163    }
164}
165
166/// Static metadata for one Steam HTTP endpoint.
167///
168/// Created by the `#[steam_endpoint(...)]` macro and submitted to the global
169/// `inventory` registry. Iterate the registry with
170/// `inventory::iter::<EndpointInfo>()`.
171#[derive(Debug, Clone, Copy)]
172pub struct EndpointInfo {
173    /// Function name (e.g. `"get_notifications"`).
174    pub name: &'static str,
175    /// Module path of the annotated function (from `module_path!()`).
176    pub module: &'static str,
177    pub method: HttpMethod,
178    pub host: Host,
179    /// URL path *template* (low-cardinality), e.g. `/profiles/{steam_id}/edit`.
180    pub path: &'static str,
181    pub kind: EndpointKind,
182}
183
184::inventory::collect!(EndpointInfo);
185
186/// Lock-free metric counters for Steam endpoint calls.
187///
188/// Lives in a `LazyLock<EndpointMetrics>` (see [`metrics`]). Counters are
189/// atomic so any task can `record_call` without contention. Snapshots can be
190/// taken at any time via [`EndpointMetrics::snapshot`].
191///
192/// Indexing scheme: fixed-size 2D array `[Host][EndpointKind]` keyed by the
193/// enum's `as usize` discriminant. Iteration order matches enum
194/// declaration order. Status codes are not tracked here — they belong on
195/// the tracing layer (`http.status_code` field).
196#[derive(Debug)]
197pub struct EndpointMetrics {
198    by_host_kind: [[AtomicU64; 5]; 5],
199    total: AtomicU64,
200}
201
202/// Plain-data snapshot of [`EndpointMetrics`]. Returned from
203/// [`EndpointMetrics::snapshot`].
204#[derive(Debug, Clone, Copy)]
205pub struct EndpointMetricsSnapshot {
206    pub by_host_kind: [[u64; 5]; 5],
207    pub total: u64,
208}
209
210/// Map a `Host` variant to its fixed array index.
211/// Using an explicit `match` (no `_` arm) ensures the compiler will catch
212/// any future variant additions that are not yet handled.
213fn host_index(host: Host) -> usize {
214    match host {
215        Host::Community => 0,
216        Host::Store     => 1,
217        Host::Help      => 2,
218        Host::Api       => 3,
219        Host::ShortLink => 4,
220    }
221}
222
223/// Map an `EndpointKind` variant to its fixed array index.
224fn kind_index(kind: EndpointKind) -> usize {
225    match kind {
226        EndpointKind::Read     => 0,
227        EndpointKind::Write    => 1,
228        EndpointKind::Auth     => 2,
229        EndpointKind::Upload   => 3,
230        EndpointKind::Recovery => 4,
231    }
232}
233
234impl EndpointMetrics {
235    const fn new() -> Self {
236        // `AtomicU64` is not `Copy`, so the usual `[AtomicU64::new(0); N]`
237        // shortcut doesn't work. Use nested inline-const blocks
238        // (stable since 1.79) so each slot is evaluated independently.
239        Self {
240            by_host_kind: [const { [const { AtomicU64::new(0) }; 5] }; 5],
241            total: AtomicU64::new(0),
242        }
243    }
244
245    /// Record one call against the given endpoint. Cheap; uses `Relaxed`
246    /// ordering since these are counters, not synchronisation primitives.
247    pub fn record_call(&self, ep: &EndpointInfo) {
248        self.by_host_kind[host_index(ep.host)][kind_index(ep.kind)].fetch_add(1, Ordering::Relaxed);
249        self.total.fetch_add(1, Ordering::Relaxed);
250    }
251
252    /// Snapshot all counters. Atomic loads only — never blocks writers.
253    pub fn snapshot(&self) -> EndpointMetricsSnapshot {
254        let mut by_host_kind = [[0u64; 5]; 5];
255        for (h, row) in self.by_host_kind.iter().enumerate() {
256            for (k, slot) in row.iter().enumerate() {
257                by_host_kind[h][k] = slot.load(Ordering::Relaxed);
258            }
259        }
260        EndpointMetricsSnapshot { by_host_kind, total: self.total.load(Ordering::Relaxed) }
261    }
262
263    /// Reset all counters to zero. Mainly for tests.
264    pub fn reset(&self) {
265        for row in &self.by_host_kind {
266            for slot in row {
267                slot.store(0, Ordering::Relaxed);
268            }
269        }
270        self.total.store(0, Ordering::Relaxed);
271    }
272}
273
274impl EndpointMetricsSnapshot {
275    /// Lookup a single counter.
276    pub fn count(&self, host: Host, kind: EndpointKind) -> u64 {
277        self.by_host_kind[host_index(host)][kind_index(kind)]
278    }
279
280    /// Total count for one host across all kinds.
281    pub fn count_by_host(&self, host: Host) -> u64 {
282        self.by_host_kind[host_index(host)].iter().sum()
283    }
284
285    /// Total count for one kind across all hosts.
286    pub fn count_by_kind(&self, kind: EndpointKind) -> u64 {
287        self.by_host_kind.iter().map(|row| row[kind_index(kind)]).sum()
288    }
289}
290
291static METRICS: std::sync::LazyLock<EndpointMetrics> = std::sync::LazyLock::new(EndpointMetrics::new);
292
293/// Global endpoint metrics counter. Updated automatically by
294/// `client::SteamRequestBuilder::send` whenever a request runs inside an
295/// `#[steam_endpoint]`-annotated method.
296pub fn metrics() -> &'static EndpointMetrics {
297    &METRICS
298}
299
300#[cfg(test)]
301mod tests {
302    use std::collections::HashSet;
303
304    use super::*;
305
306    fn registry() -> Vec<&'static EndpointInfo> {
307        ::inventory::iter::<EndpointInfo>().collect()
308    }
309
310    #[test]
311    fn registry_has_full_entries() {
312        // Full annotation pass produced 144 endpoints. Allow drift in either
313        // direction but flag big regressions (e.g. macro silently dropped on
314        // refactor) and large jumps (e.g. duplicate registration). The
315        // services_annotated integration test enforces presence per-method;
316        // this one catches macro-level regressions.
317        let endpoints = registry();
318        assert!(
319            endpoints.len() >= 130,
320            "registry shrunk: {} endpoints — expected ~144, did the macro stop firing?",
321            endpoints.len(),
322        );
323        assert!(
324            endpoints.len() <= 200,
325            "registry grew unexpectedly: {} endpoints — duplicate registration?",
326            endpoints.len(),
327        );
328    }
329
330    #[test]
331    fn no_duplicate_endpoints() {
332        let mut seen: HashSet<(&str, &str)> = HashSet::new();
333        for ep in registry() {
334            let key = (ep.module, ep.name);
335            assert!(seen.insert(key), "duplicate endpoint: {}::{}", ep.module, ep.name);
336        }
337    }
338
339    #[test]
340    fn get_notifications_metadata() {
341        let ep = registry()
342            .into_iter()
343            .find(|e| e.name == "get_notifications")
344            .expect("get_notifications must be registered");
345        assert_eq!(ep.method, HttpMethod::Get);
346        assert_eq!(ep.host, Host::Community);
347        assert_eq!(ep.path, "/actions/GetNotificationCounts");
348        assert_eq!(ep.kind, EndpointKind::Read);
349    }
350
351    #[test]
352    fn get_player_reports_metadata() {
353        let ep = registry()
354            .into_iter()
355            .find(|e| e.name == "get_player_reports")
356            .expect("get_player_reports must be registered");
357        assert_eq!(ep.method, HttpMethod::Get);
358        assert_eq!(ep.host, Host::Community);
359        assert_eq!(ep.path, "/my/reports/");
360        assert_eq!(ep.kind, EndpointKind::Read);
361    }
362
363    #[test]
364    fn host_hostname_strings() {
365        assert_eq!(Host::Community.hostname(), "steamcommunity.com");
366        assert_eq!(Host::Store.hostname(), "store.steampowered.com");
367        assert_eq!(Host::Help.hostname(), "help.steampowered.com");
368        assert_eq!(Host::Api.hostname(), "api.steampowered.com");
369    }
370
371    #[test]
372    fn host_base_url_strings() {
373        assert_eq!(Host::Community.base_url(), "https://steamcommunity.com");
374        assert_eq!(Host::Store.base_url(), "https://store.steampowered.com");
375        assert_eq!(Host::Help.base_url(), "https://help.steampowered.com");
376        assert_eq!(Host::Api.base_url(), "https://api.steampowered.com");
377    }
378
379    #[test]
380    fn metrics_record_increments_correct_slots() {
381        // Use a *local* metrics instance so this test doesn't race the
382        // global one (other tests / app code may also be incrementing).
383        let m = EndpointMetrics::new();
384        let ep_read = EndpointInfo {
385            name: "x", module: "test", method: HttpMethod::Get,
386            host: Host::Community, path: "/x", kind: EndpointKind::Read,
387        };
388        let ep_recovery = EndpointInfo {
389            name: "y", module: "test", method: HttpMethod::Post,
390            host: Host::Help, path: "/y", kind: EndpointKind::Recovery,
391        };
392
393        m.record_call(&ep_read);
394        m.record_call(&ep_read);
395        m.record_call(&ep_recovery);
396
397        let snap = m.snapshot();
398        assert_eq!(snap.total, 3);
399        assert_eq!(snap.count(Host::Community, EndpointKind::Read), 2);
400        assert_eq!(snap.count(Host::Help, EndpointKind::Recovery), 1);
401        assert_eq!(snap.count(Host::Community, EndpointKind::Write), 0);
402        assert_eq!(snap.count_by_host(Host::Community), 2);
403        assert_eq!(snap.count_by_kind(EndpointKind::Read), 2);
404        assert_eq!(snap.count_by_kind(EndpointKind::Recovery), 1);
405    }
406
407    #[tokio::test]
408    async fn task_local_propagates_endpoint() {
409        // Outside any annotated method, current_endpoint() is None.
410        assert!(current_endpoint().is_none());
411
412        static EP: EndpointInfo = EndpointInfo {
413            name: "demo", module: "test", method: HttpMethod::Get,
414            host: Host::Community, path: "/demo", kind: EndpointKind::Read,
415        };
416
417        CURRENT_ENDPOINT
418            .scope(&EP, async move {
419                let inner = current_endpoint().expect("set inside scope");
420                assert_eq!(inner.name, "demo");
421                assert_eq!(inner.host, Host::Community);
422            })
423            .await;
424
425        // Restored after scope.
426        assert!(current_endpoint().is_none());
427    }
428}