Skip to main content

wowlab_centrifuge/
presence.rs

1use std::time::Duration;
2
3use serde_json::json;
4use wowlab_common::NodePublicKey;
5use wowlab_types::{constants::HTTP_CONNECT_TIMEOUT_SECS, sensitive::Sensitive, sim::FastSet};
6
7use crate::Error;
8
9/// HTTP client for querying Centrifugo channel presence.
10#[derive(Clone, Debug)]
11// #t(rust_sensitive_debug) api_key is wrapped in Sensitive<String> which redacts on Debug
12pub struct Presence {
13    http: reqwest::Client,
14    url: String,
15    api_key: Sensitive<String>,
16}
17
18impl Presence {
19    /// Creates a presence client for a Centrifugo HTTP API endpoint.
20    #[must_use]
21    pub fn new(url: impl Into<String>, api_key: impl Into<String>) -> Self {
22        Self {
23            http: reqwest::Client::builder()
24                .timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
25                .build()
26                .unwrap_or_else(|_| reqwest::Client::new()),
27            url: url.into().trim_end_matches('/').to_string(),
28            api_key: Sensitive::new(api_key.into()),
29        }
30    }
31
32    // docref:start realtime-presence-read
33    /// Returns the node keys currently present on `channel`.
34    /// # Errors
35    /// Returns an error if the HTTP request, response decoding, or Centrifugo API operation fails.
36    pub async fn get_online(&self, channel: &str) -> Result<FastSet<NodePublicKey>, Error> {
37        let resp = self
38            .http
39            .post(format!("{}/api/presence", self.url))
40            .header("X-API-Key", self.api_key.expose())
41            .json(&json!({ "channel": channel }))
42            .send()
43            .await?;
44        // docref:end realtime-presence-read
45
46        if !resp.status().is_success() {
47            return Err(Error::http_status(resp.status()));
48        }
49
50        let body: serde_json::Value = resp.json().await?;
51
52        if let Some(error) = body.get("error") {
53            let code = error
54                .get("code")
55                .and_then(serde_json::Value::as_u64)
56                .and_then(|code| u32::try_from(code).ok())
57                .unwrap_or(0);
58            let message = error
59                .get("message")
60                .and_then(|m| m.as_str())
61                .unwrap_or("unknown")
62                .to_string();
63
64            return Err(Error::server(code, message, false));
65        }
66
67        let presence = body
68            .get("result")
69            .and_then(|r| r.get("presence"))
70            .and_then(|p| p.as_object());
71        let keys = presence
72            .map(|obj| {
73                obj.values()
74                    .filter_map(|v| v.get("user").and_then(|u| u.as_str()))
75                    .filter_map(|s| s.parse::<NodePublicKey>().ok())
76                    .collect()
77            })
78            .unwrap_or_default();
79
80        Ok(keys)
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use googletest::prelude::*;
87
88    use super::Presence;
89
90    #[gtest]
91    fn debug_redacts_api_key() -> Result<()> {
92        let presence = Presence::new("https://centrifugo.example.com", "presence-secret");
93        let debug = format!("{presence:?}");
94
95        verify_that!(debug.as_str(), contains_substring("[REDACTED]"))?;
96
97        verify_that!(debug.as_str(), not(contains_substring("presence-secret")))
98    }
99}