1use std::sync::Arc;
4
5use futures::lock::Mutex;
6
7use crate::config::{Auth, TokenProvider};
8use crate::error::Result;
9
10#[derive(Clone)]
15pub struct Client {
16 pub(crate) inner: Arc<Inner>,
17}
18
19pub(crate) struct Inner {
21 pub(crate) http: reqwest::Client,
22 pub(crate) base: String,
24 pub(crate) auth: AuthState,
26 pub(crate) max_retries: u32,
27}
28
29pub(crate) enum AuthState {
31 Static(String),
32 Provider {
33 provider: Arc<dyn TokenProvider>,
34 cache: Mutex<Option<String>>,
35 },
36}
37
38impl Inner {
39 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 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 pub fn builder(auth: Auth) -> ClientBuilder {
86 ClientBuilder::new(auth)
87 }
88
89 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
98pub 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 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 pub fn host(mut self, host: impl Into<String>) -> Self {
122 self.host = host.into();
123 self
124 }
125
126 pub fn http_client(mut self, client: reqwest::Client) -> Self {
128 self.http = Some(client);
129 self
130 }
131
132 pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
135 self.timeout = Some(timeout);
136 self
137 }
138
139 pub fn max_retries(mut self, n: u32) -> Self {
142 self.max_retries = n;
143 self
144 }
145
146 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")); 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 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 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}