1use std::{
43 fmt,
44 sync::atomic::{AtomicU64, Ordering},
45};
46
47#[doc(hidden)]
52pub use steam_user_impl::steam_endpoint;
53
54::tokio::task_local! {
55 pub static CURRENT_ENDPOINT: &'static EndpointInfo;
65}
66
67pub fn current_endpoint() -> Option<&'static EndpointInfo> {
72 CURRENT_ENDPOINT.try_with(|ep| *ep).ok()
73}
74
75#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub enum Host {
98 Community,
100 Store,
102 Help,
104 Api,
106 ShortLink,
110}
111
112impl Host {
113 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 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#[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#[derive(Debug, Clone, Copy)]
172pub struct EndpointInfo {
173 pub name: &'static str,
175 pub module: &'static str,
177 pub method: HttpMethod,
178 pub host: Host,
179 pub path: &'static str,
181 pub kind: EndpointKind,
182}
183
184::inventory::collect!(EndpointInfo);
185
186#[derive(Debug)]
197pub struct EndpointMetrics {
198 by_host_kind: [[AtomicU64; 5]; 5],
199 total: AtomicU64,
200}
201
202#[derive(Debug, Clone, Copy)]
205pub struct EndpointMetricsSnapshot {
206 pub by_host_kind: [[u64; 5]; 5],
207 pub total: u64,
208}
209
210fn 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
223fn 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 Self {
240 by_host_kind: [const { [const { AtomicU64::new(0) }; 5] }; 5],
241 total: AtomicU64::new(0),
242 }
243 }
244
245 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 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 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 pub fn count(&self, host: Host, kind: EndpointKind) -> u64 {
277 self.by_host_kind[host_index(host)][kind_index(kind)]
278 }
279
280 pub fn count_by_host(&self, host: Host) -> u64 {
282 self.by_host_kind[host_index(host)].iter().sum()
283 }
284
285 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
293pub 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 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 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 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 assert!(current_endpoint().is_none());
427 }
428}