steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! Endpoint metadata for Steam HTTP methods.
//!
//! Every public method in [`crate::services`] that issues an HTTP request to a
//! Steam network endpoint is annotated with `#[steam_endpoint(...)]` (from the
//! internal `steam-user-impl` crate, re-exported from this module). The annotation:
//!
//! 1. Wraps the function with a `tracing::instrument` span carrying
//!    `steam.endpoint.method/host/path/kind` fields and `steam.module`.
//! 2. Submits a static [`EndpointInfo`] entry to a global registry collected
//!    by the `inventory` crate.
//!
//! Anywhere in the program can list every Steam endpoint at runtime:
//!
//! ```ignore
//! for ep in inventory::iter::<steam_user::endpoint::EndpointInfo>() {
//!     println!("{} {:?}{} ({})", ep.method, ep.host, ep.path, ep.kind);
//! }
//! ```
//!
//! # Path templates, not resolved URLs
//!
//! `path` is a *template* like `/profiles/{steam_id}/edit/settings`. It is
//! used purely as a low-cardinality label for metrics and logs — it is not
//! parsed or used to build the actual request. The full URL is constructed
//! inside the function body by the existing client code.
//!
//! # `EndpointKind`
//!
//! Endpoints fall into five categories with very different operational
//! characteristics:
//!
//! - [`Read`](EndpointKind::Read): idempotent GET. Safe to retry.
//! - [`Write`](EndpointKind::Write): mutates server state. Retry only if the
//!   protocol guarantees idempotency at the application layer.
//! - [`Auth`](EndpointKind::Auth): login / 2FA. Steam IP-bans on spammy retry.
//! - [`Upload`](EndpointKind::Upload): file or avatar upload. Use long
//!   timeouts.
//! - [`Recovery`](EndpointKind::Recovery): account-recovery wizard on
//!   `help.steampowered.com`. **Locks the account on too many wrong attempts.**
//!   Retry with extreme care.

use std::{
    fmt,
    sync::atomic::{AtomicU64, Ordering},
};

// Re-exported from the internal `steam-user-impl` proc-macro crate so users
// only need `steam-user` in their `Cargo.toml`. `#[doc(hidden)]` hides the
// re-export path in rustdoc; consumers should refer to it via the macro's own
// documentation rather than the `steam_user_impl::` path.
#[doc(hidden)]
pub use steam_user_impl::steam_endpoint;

::tokio::task_local! {
    /// Active endpoint for the running task.
    ///
    /// Set automatically by the `#[steam_endpoint(...)]` macro before the
    /// function body executes. Read by `client::SteamRequestBuilder::send`
    /// (and the kind-aware retry strategy) so HTTP-level behaviour can vary
    /// based on the endpoint's host/kind without threading the metadata
    /// through every helper.
    ///
    /// Use [`current_endpoint`] to read it; do not call `try_with` directly.
    pub static CURRENT_ENDPOINT: &'static EndpointInfo;
}

/// Returns the active [`EndpointInfo`] for the running task, if any.
///
/// `None` when called outside an `#[steam_endpoint]`-annotated method (e.g.
/// from `client::logged_in` or background helpers).
pub fn current_endpoint() -> Option<&'static EndpointInfo> {
    CURRENT_ENDPOINT.try_with(|ep| *ep).ok()
}

/// HTTP verb for a Steam endpoint.
#[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",
        })
    }
}

/// Steam network host that an endpoint targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Host {
    /// `steamcommunity.com` — community web pages and AJAX endpoints.
    Community,
    /// `store.steampowered.com` — store, account, and twofactor operations.
    Store,
    /// `help.steampowered.com` — recovery wizard, help requests.
    Help,
    /// `api.steampowered.com` — public WebAPI services.
    Api,
    /// `s.team` — Steam's URL shortener / short-link redirector. Issues a
    /// 302 to a target on Community/Store. The cookie jar carries Steam
    /// cookies through the redirect on the regular client.
    ShortLink,
}

impl Host {
    /// Returns the canonical hostname (without scheme).
    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",
        }
    }

    /// Returns the HTTPS base URL (scheme + hostname, no trailing slash).
    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())
    }
}

/// Operational category of a Steam endpoint.
///
/// See the module-level docs for retry / rate-limit implications.
#[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",
        })
    }
}

/// Static metadata for one Steam HTTP endpoint.
///
/// Created by the `#[steam_endpoint(...)]` macro and submitted to the global
/// `inventory` registry. Iterate the registry with
/// `inventory::iter::<EndpointInfo>()`.
#[derive(Debug, Clone, Copy)]
pub struct EndpointInfo {
    /// Function name (e.g. `"get_notifications"`).
    pub name: &'static str,
    /// Module path of the annotated function (from `module_path!()`).
    pub module: &'static str,
    pub method: HttpMethod,
    pub host: Host,
    /// URL path *template* (low-cardinality), e.g. `/profiles/{steam_id}/edit`.
    pub path: &'static str,
    pub kind: EndpointKind,
}

::inventory::collect!(EndpointInfo);

/// Lock-free metric counters for Steam endpoint calls.
///
/// Lives in a `LazyLock<EndpointMetrics>` (see [`metrics`]). Counters are
/// atomic so any task can `record_call` without contention. Snapshots can be
/// taken at any time via [`EndpointMetrics::snapshot`].
///
/// Indexing scheme: fixed-size 2D array `[Host][EndpointKind]` keyed by the
/// enum's `as usize` discriminant. Iteration order matches enum
/// declaration order. Status codes are not tracked here — they belong on
/// the tracing layer (`http.status_code` field).
#[derive(Debug)]
pub struct EndpointMetrics {
    by_host_kind: [[AtomicU64; 5]; 5],
    total: AtomicU64,
}

/// Plain-data snapshot of [`EndpointMetrics`]. Returned from
/// [`EndpointMetrics::snapshot`].
#[derive(Debug, Clone, Copy)]
pub struct EndpointMetricsSnapshot {
    pub by_host_kind: [[u64; 5]; 5],
    pub total: u64,
}

/// Map a `Host` variant to its fixed array index.
/// Using an explicit `match` (no `_` arm) ensures the compiler will catch
/// any future variant additions that are not yet handled.
fn host_index(host: Host) -> usize {
    match host {
        Host::Community => 0,
        Host::Store     => 1,
        Host::Help      => 2,
        Host::Api       => 3,
        Host::ShortLink => 4,
    }
}

/// Map an `EndpointKind` variant to its fixed array index.
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 {
        // `AtomicU64` is not `Copy`, so the usual `[AtomicU64::new(0); N]`
        // shortcut doesn't work. Use nested inline-const blocks
        // (stable since 1.79) so each slot is evaluated independently.
        Self {
            by_host_kind: [const { [const { AtomicU64::new(0) }; 5] }; 5],
            total: AtomicU64::new(0),
        }
    }

    /// Record one call against the given endpoint. Cheap; uses `Relaxed`
    /// ordering since these are counters, not synchronisation primitives.
    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);
    }

    /// Snapshot all counters. Atomic loads only — never blocks writers.
    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) }
    }

    /// Reset all counters to zero. Mainly for tests.
    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 {
    /// Lookup a single counter.
    pub fn count(&self, host: Host, kind: EndpointKind) -> u64 {
        self.by_host_kind[host_index(host)][kind_index(kind)]
    }

    /// Total count for one host across all kinds.
    pub fn count_by_host(&self, host: Host) -> u64 {
        self.by_host_kind[host_index(host)].iter().sum()
    }

    /// Total count for one kind across all hosts.
    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);

/// Global endpoint metrics counter. Updated automatically by
/// `client::SteamRequestBuilder::send` whenever a request runs inside an
/// `#[steam_endpoint]`-annotated method.
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() {
        // Full annotation pass produced 144 endpoints. Allow drift in either
        // direction but flag big regressions (e.g. macro silently dropped on
        // refactor) and large jumps (e.g. duplicate registration). The
        // services_annotated integration test enforces presence per-method;
        // this one catches macro-level regressions.
        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() {
        // Use a *local* metrics instance so this test doesn't race the
        // global one (other tests / app code may also be incrementing).
        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() {
        // Outside any annotated method, current_endpoint() is None.
        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;

        // Restored after scope.
        assert!(current_endpoint().is_none());
    }
}