Skip to main content

ably_chat/
client.rs

1//! The `Client` handle, its builder, and the shared inner state (ADR-0003).
2
3use std::sync::Arc;
4
5use futures::lock::Mutex;
6
7use crate::config::{Auth, TokenProvider};
8use crate::error::Result;
9
10/// The entry point to the Ably Chat REST API.
11///
12/// Cheap to `Clone` (`Arc`-backed) and `Send + Sync`. Its `Debug`
13/// representation never prints credentials.
14#[derive(Clone)]
15pub struct Client {
16    pub(crate) inner: Arc<Inner>,
17}
18
19/// Shared, immutable client state behind an `Arc`.
20pub(crate) struct Inner {
21    pub(crate) http: reqwest::Client,
22    /// Base host with any trailing slash trimmed, e.g. `https://rest.ably.io`.
23    pub(crate) base: String,
24    /// Resolved auth: a fixed header, or a provider + cached header.
25    pub(crate) auth: AuthState,
26    pub(crate) max_retries: u32,
27}
28
29/// Resolved auth: a fixed header for static creds, or a provider + cached header.
30pub(crate) enum AuthState {
31    Static(String),
32    Provider {
33        provider: Arc<dyn TokenProvider>,
34        cache: Mutex<Option<String>>,
35    },
36}
37
38impl Inner {
39    /// The `Authorization` header value. For a provider, returns the cached
40    /// header. The provider is called only when nothing is cached yet, or
41    /// when `stale` names the exact header value that a caller just had
42    /// rejected: if another task already refreshed the cache since then, its
43    /// value is reused instead of minting a redundant token.
44    ///
45    /// `stale`: `None` for a normal request (use whatever is cached, or fetch
46    /// if empty); `Some(header)` when retrying after `header` was rejected as
47    /// a token error.
48    pub(crate) async fn auth_header(&self, stale: Option<&str>) -> Result<String> {
49        match &self.auth {
50            AuthState::Static(h) => Ok(h.clone()),
51            AuthState::Provider { provider, cache } => {
52                // The mutex is deliberately held across `provider.token()`
53                // below (not just this cache read): that is what makes
54                // concurrent refreshes single-flighted. The first caller to
55                // take the lock fetches and caches; every other caller that
56                // arrives while the fetch is in flight blocks here, then
57                // observes the freshly cached value and reuses it. The guard
58                // is released long before the outbound HTTP send, which
59                // happens in `dispatch::send_url` after this returns.
60                let mut guard = cache.lock().await;
61                if let Some(cached) = guard.as_ref()
62                    && stale != Some(cached.as_str())
63                {
64                    return Ok(cached.clone());
65                }
66                let header = format!("Bearer {}", provider.token().await?);
67                *guard = Some(header.clone());
68                Ok(header)
69            }
70        }
71    }
72}
73
74impl std::fmt::Debug for Client {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("Client")
77            .field("base", &self.inner.base)
78            .field("auth", &"<redacted>")
79            .finish()
80    }
81}
82
83impl Client {
84    /// Starts building a client with the given credentials.
85    pub fn builder(auth: Auth) -> ClientBuilder {
86        ClientBuilder::new(auth)
87    }
88
89    /// Returns a handle to the named chat room.
90    ///
91    /// Rooms are implicit: this neither creates nor deletes anything
92    /// server-side.
93    pub fn room(&self, name: impl Into<crate::types::RoomName>) -> crate::room::Room {
94        crate::room::Room::new(self.clone(), name.into())
95    }
96}
97
98/// Builder for [`Client`]. Requires credentials; host, timeout, retry budget,
99/// and a caller-supplied `reqwest::Client` are optional.
100pub struct ClientBuilder {
101    auth: Auth,
102    host: String,
103    http: Option<reqwest::Client>,
104    timeout: Option<std::time::Duration>,
105    max_retries: u32,
106}
107
108impl ClientBuilder {
109    /// Creates a builder with the default host and retry budget.
110    pub fn new(auth: Auth) -> Self {
111        Self {
112            auth,
113            host: "https://rest.ably.io".into(),
114            http: None,
115            timeout: None,
116            max_retries: 3,
117        }
118    }
119
120    /// Overrides the base host (e.g. to point at a test server).
121    pub fn host(mut self, host: impl Into<String>) -> Self {
122        self.host = host.into();
123        self
124    }
125
126    /// Supplies a preconfigured `reqwest::Client` (overrides `timeout`).
127    pub fn http_client(mut self, client: reqwest::Client) -> Self {
128        self.http = Some(client);
129        self
130    }
131
132    /// Sets the per-request timeout used when this builder constructs the
133    /// `reqwest::Client` itself.
134    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
135        self.timeout = Some(timeout);
136        self
137    }
138
139    /// Sets the maximum number of retries for retry-eligible requests
140    /// (ADR-0006). Defaults to `3`.
141    pub fn max_retries(mut self, n: u32) -> Self {
142        self.max_retries = n;
143        self
144    }
145
146    /// Builds the [`Client`].
147    ///
148    /// # Panics
149    ///
150    /// Panics only if the underlying `reqwest::Client` cannot be constructed
151    /// (e.g. the platform TLS backend fails to initialise).
152    pub fn build(self) -> Client {
153        let http = self.http.unwrap_or_else(|| {
154            let mut b = reqwest::Client::builder();
155            if let Some(t) = self.timeout {
156                b = b.timeout(t);
157            }
158            b.build().expect("failed to build reqwest client")
159        });
160        let auth = match self.auth {
161            Auth::Provider(p) => AuthState::Provider {
162                provider: p,
163                cache: Mutex::new(None),
164            },
165            a @ (Auth::ApiKey(_) | Auth::Token(_)) => AuthState::Static(a.header_value()),
166        };
167        Client {
168            inner: Arc::new(Inner {
169                http,
170                base: self.host.trim_end_matches('/').to_string(),
171                auth,
172                max_retries: self.max_retries,
173            }),
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn client_debug_redacts_credentials() {
184        let client = Client::builder(Auth::api_key("app.key:supersecret"))
185            .host("https://example.test")
186            .build();
187        let dbg = format!("{client:?}");
188        assert!(!dbg.contains("supersecret"));
189        assert!(!dbg.contains("YXBw")); // no base64 of the key either
190        assert!(dbg.contains("https://example.test"));
191    }
192
193    #[test]
194    fn host_trailing_slash_is_trimmed() {
195        let client = Client::builder(Auth::token("t"))
196            .host("https://example.test/")
197            .build();
198        assert_eq!(client.inner.base, "https://example.test");
199    }
200
201    #[tokio::test]
202    async fn provider_auth_header_resolves_and_caches() {
203        use crate::config::TokenProvider;
204        use futures::future::BoxFuture;
205        use std::sync::Arc;
206        use std::sync::atomic::{AtomicUsize, Ordering};
207
208        struct Counting(Arc<AtomicUsize>);
209        impl TokenProvider for Counting {
210            fn token(&self) -> BoxFuture<'_, crate::error::Result<String>> {
211                self.0.fetch_add(1, Ordering::SeqCst);
212                Box::pin(async { Ok("tok-1".to_string()) })
213            }
214        }
215        let calls = Arc::new(AtomicUsize::new(0));
216        let client = Client::builder(crate::config::Auth::provider(Arc::new(Counting(
217            calls.clone(),
218        ))))
219        .build();
220
221        let h1 = client.inner.auth_header(None).await.unwrap();
222        let h2 = client.inner.auth_header(None).await.unwrap();
223        assert_eq!(h1, "Bearer tok-1");
224        assert_eq!(h2, "Bearer tok-1");
225        assert_eq!(
226            calls.load(Ordering::SeqCst),
227            1,
228            "second call served from cache"
229        );
230
231        // forced refresh of the (still-current) cached value re-fetches
232        client
233            .inner
234            .auth_header(Some("Bearer tok-1"))
235            .await
236            .unwrap();
237        assert_eq!(calls.load(Ordering::SeqCst), 2);
238    }
239
240    #[tokio::test]
241    async fn forced_refresh_is_single_flighted() {
242        use crate::config::TokenProvider;
243        use futures::future::BoxFuture;
244        use std::sync::Arc;
245        use std::sync::atomic::{AtomicUsize, Ordering};
246
247        struct Rotating(Arc<AtomicUsize>);
248        impl TokenProvider for Rotating {
249            fn token(&self) -> BoxFuture<'_, crate::error::Result<String>> {
250                let n = self.0.fetch_add(1, Ordering::SeqCst);
251                Box::pin(async move { Ok(format!("t{}", n + 1)) })
252            }
253        }
254        let calls = Arc::new(AtomicUsize::new(0));
255        let client = Client::builder(crate::config::Auth::provider(Arc::new(Rotating(
256            calls.clone(),
257        ))))
258        .build();
259
260        let first = client.inner.auth_header(None).await.unwrap();
261        assert_eq!(first, "Bearer t1");
262        assert_eq!(calls.load(Ordering::SeqCst), 1);
263
264        // Two requests concurrently discover the same stale token: exactly ONE refresh.
265        let (a, b) = futures::join!(
266            client.inner.auth_header(Some("Bearer t1")),
267            client.inner.auth_header(Some("Bearer t1")),
268        );
269        let (a, b) = (a.unwrap(), b.unwrap());
270        assert_eq!(
271            calls.load(Ordering::SeqCst),
272            2,
273            "second caller must reuse the refreshed token"
274        );
275        assert_eq!(a, "Bearer t2");
276        assert_eq!(b, "Bearer t2");
277    }
278}