1use std::{
9 fmt,
10 sync::{Arc, Mutex},
11};
12
13use futures_util::future::BoxFuture;
14use inline_sdk::{ClientIdentity, RealtimeClient, RealtimeError};
15
16use crate::{AuthToken, BackendError, BackendResult, ClientErrorCategory};
17
18#[derive(Clone, PartialEq, Eq)]
20pub struct RealtimeConnectRequest {
21 realtime_url: String,
22 auth_token: AuthToken,
23 identity: ClientIdentity,
24}
25
26impl RealtimeConnectRequest {
27 pub fn new(
29 realtime_url: impl Into<String>,
30 auth_token: AuthToken,
31 identity: ClientIdentity,
32 ) -> Self {
33 Self {
34 realtime_url: realtime_url.into(),
35 auth_token,
36 identity,
37 }
38 }
39
40 pub fn realtime_url(&self) -> &str {
42 &self.realtime_url
43 }
44
45 pub fn auth_token(&self) -> &AuthToken {
47 &self.auth_token
48 }
49
50 pub fn identity(&self) -> &ClientIdentity {
52 &self.identity
53 }
54}
55
56impl fmt::Debug for RealtimeConnectRequest {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.debug_struct("RealtimeConnectRequest")
59 .field("realtime_url", &redacted_url_for_debug(&self.realtime_url))
60 .field("auth_token", &"[redacted]")
61 .field("identity", &self.identity)
62 .finish()
63 }
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct RealtimeConnectionInfo {
69 pub realtime_url: String,
71 pub identity: ClientIdentity,
73}
74
75impl RealtimeConnectionInfo {
76 pub fn new(realtime_url: impl Into<String>, identity: ClientIdentity) -> Self {
78 Self {
79 realtime_url: realtime_url.into(),
80 identity,
81 }
82 }
83}
84
85pub trait RealtimeConnector: fmt::Debug + Send + Sync + 'static {
87 fn connect(
89 &self,
90 request: RealtimeConnectRequest,
91 ) -> BoxFuture<'static, BackendResult<RealtimeConnectionInfo>>;
92}
93
94#[derive(Clone, Debug, Default)]
96pub struct SdkRealtimeConnector;
97
98impl SdkRealtimeConnector {
99 pub fn new() -> Self {
101 Self
102 }
103}
104
105impl RealtimeConnector for SdkRealtimeConnector {
106 fn connect(
107 &self,
108 request: RealtimeConnectRequest,
109 ) -> BoxFuture<'static, BackendResult<RealtimeConnectionInfo>> {
110 Box::pin(async move {
111 let identity = request.identity;
112 let realtime_url = request.realtime_url;
113 let token = request.auth_token;
114 let debug_url = redacted_url_for_debug(&realtime_url);
115 log::debug!(
116 "connecting Inline realtime transport at {debug_url} as {}",
117 identity.client_type()
118 );
119 let _client = RealtimeClient::connect_with_identity(
120 &realtime_url,
121 token.expose_secret(),
122 identity.clone(),
123 )
124 .await
125 .map_err(realtime_error_to_backend)?;
126 log::debug!("Inline realtime transport handshake completed");
127 Ok(RealtimeConnectionInfo::new(realtime_url, identity))
128 })
129 }
130}
131
132#[derive(Clone, Debug, Default)]
134pub struct FakeRealtimeConnector {
135 state: Arc<Mutex<FakeRealtimeState>>,
136}
137
138#[derive(Clone, Debug, Default)]
139struct FakeRealtimeState {
140 attempts: Vec<FakeRealtimeAttempt>,
141 failure: Option<BackendError>,
142}
143
144#[derive(Clone, Debug, PartialEq, Eq)]
146pub struct FakeRealtimeAttempt {
147 pub realtime_url: String,
149 pub identity: ClientIdentity,
151}
152
153impl FakeRealtimeConnector {
154 pub fn new() -> Self {
156 Self::default()
157 }
158
159 pub fn failing(error: BackendError) -> Self {
161 Self {
162 state: Arc::new(Mutex::new(FakeRealtimeState {
163 attempts: Vec::new(),
164 failure: Some(error),
165 })),
166 }
167 }
168
169 pub fn attempts(&self) -> Vec<FakeRealtimeAttempt> {
171 self.state
172 .lock()
173 .expect("fake realtime connector poisoned")
174 .attempts
175 .clone()
176 }
177}
178
179impl RealtimeConnector for FakeRealtimeConnector {
180 fn connect(
181 &self,
182 request: RealtimeConnectRequest,
183 ) -> BoxFuture<'static, BackendResult<RealtimeConnectionInfo>> {
184 let connector = self.clone();
185 Box::pin(async move {
186 let mut state = connector
187 .state
188 .lock()
189 .expect("fake realtime connector poisoned");
190 state.attempts.push(FakeRealtimeAttempt {
191 realtime_url: request.realtime_url.clone(),
192 identity: request.identity.clone(),
193 });
194 if let Some(error) = state.failure.clone() {
195 return Err(error);
196 }
197 Ok(RealtimeConnectionInfo::new(
198 request.realtime_url,
199 request.identity,
200 ))
201 })
202 }
203}
204
205fn realtime_error_to_backend(error: RealtimeError) -> BackendError {
206 match error {
207 RealtimeError::InvalidUrl { message, .. } => {
208 BackendError::new(ClientErrorCategory::InvalidInput, message)
209 }
210 RealtimeError::Timeout { .. } => {
211 BackendError::new(ClientErrorCategory::Timeout, error.to_string())
212 }
213 RealtimeError::ConnectionError { .. } | RealtimeError::RpcError { .. } => {
214 BackendError::new(ClientErrorCategory::AuthExpired, error.to_string())
215 }
216 RealtimeError::ConnectionClosed | RealtimeError::WebSocket(_) => {
217 BackendError::new(ClientErrorCategory::Network, error.to_string())
218 }
219 RealtimeError::InvalidHeaderValue { .. }
220 | RealtimeError::Protocol(_)
221 | RealtimeError::MissingResult
222 | RealtimeError::UnexpectedResult { .. } => {
223 BackendError::new(ClientErrorCategory::ProtocolMismatch, error.to_string())
224 }
225 _ => BackendError::new(ClientErrorCategory::Internal, error.to_string()),
226 }
227}
228
229pub(crate) fn redacted_url_for_debug(url: &str) -> String {
230 let without_fragment = url.split('#').next().unwrap_or(url);
231 let without_query = without_fragment
232 .split('?')
233 .next()
234 .unwrap_or(without_fragment);
235 match without_query.split_once("://") {
236 Some((scheme, rest)) => {
237 let host_and_path = rest.rsplit_once('@').map_or(rest, |(_, tail)| tail);
238 format!("{scheme}://{host_and_path}")
239 }
240 None => without_query.to_owned(),
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn realtime_connect_request_debug_redacts_token_and_url_credentials() {
250 let request = RealtimeConnectRequest::new(
251 "wss://user:secret@api.inline.chat/realtime?token=secret#frag",
252 AuthToken::try_new("secret-token").unwrap(),
253 ClientIdentity::new("test", "0.1.0"),
254 );
255
256 let rendered = format!("{request:?}");
257 assert!(rendered.contains("wss://api.inline.chat/realtime"));
258 assert!(!rendered.contains("secret-token"));
259 assert!(!rendered.contains("token="));
260 }
261
262 #[tokio::test]
263 async fn fake_realtime_connector_records_redacted_attempts() {
264 let connector = FakeRealtimeConnector::new();
265 let request = RealtimeConnectRequest::new(
266 "wss://api.inline.chat/realtime",
267 AuthToken::try_new("secret-token").unwrap(),
268 ClientIdentity::new("test", "0.1.0"),
269 );
270
271 let info = connector.connect(request).await.unwrap();
272
273 assert_eq!(info.realtime_url, "wss://api.inline.chat/realtime");
274 let attempts = connector.attempts();
275 assert_eq!(attempts.len(), 1);
276 assert_eq!(attempts[0].identity.client_type(), "test");
277 }
278
279 #[tokio::test]
280 async fn fake_realtime_connector_can_fail() {
281 let connector = FakeRealtimeConnector::failing(BackendError::new(
282 ClientErrorCategory::Network,
283 "offline",
284 ));
285 let request = RealtimeConnectRequest::new(
286 "wss://api.inline.chat/realtime",
287 AuthToken::try_new("secret-token").unwrap(),
288 ClientIdentity::new("test", "0.1.0"),
289 );
290
291 let error = connector.connect(request).await.unwrap_err();
292
293 assert_eq!(error.category, ClientErrorCategory::Network);
294 }
295}