Skip to main content

inline_sdk/
client_info.rs

1//! Client identity and metadata helpers shared by HTTP and realtime clients.
2
3use reqwest::header::{HeaderMap, HeaderValue};
4use std::process::Command;
5use thiserror::Error;
6
7/// Default client type used when callers do not provide an application identity.
8pub const SDK_CLIENT_TYPE: &str = "rust-sdk";
9/// HTTP header carrying the Inline client type.
10pub const CLIENT_TYPE_HEADER: &str = "x-inline-client-type";
11/// HTTP header carrying the Inline client version.
12pub const CLIENT_VERSION_HEADER: &str = "x-inline-client-version";
13
14/// Error returned when a client identity cannot be represented in Inline headers.
15#[derive(Clone, Debug, Error, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum ClientIdentityError {
18    /// The given field is empty after trimming whitespace.
19    #[error("{field} cannot be empty")]
20    Empty {
21        /// Name of the invalid identity field.
22        field: &'static str,
23    },
24    /// The given field contains bytes rejected by `HeaderValue`.
25    #[error("{field} contains characters that are invalid in HTTP headers")]
26    InvalidHeaderValue {
27        /// Name of the invalid identity field.
28        field: &'static str,
29    },
30}
31
32/// Application identity sent with Inline HTTP and realtime requests.
33///
34/// Use `ClientIdentity::sdk()` for low-level SDK callers, or pass your own
35/// application-specific type such as `cli`, `matrix-bridge`, or `agent`.
36#[must_use]
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct ClientIdentity {
39    client_type: String,
40    client_version: String,
41}
42
43impl ClientIdentity {
44    /// Creates a client identity and panics if the values are invalid.
45    ///
46    /// This is intended for static or already-validated values. Use
47    /// [`ClientIdentity::try_new`] when accepting user or config input.
48    ///
49    /// # Panics
50    ///
51    /// Panics when either field is empty after trimming or cannot be represented
52    /// as an HTTP header value.
53    pub fn new(
54        client_type: impl Into<String>,
55        client_version: impl Into<String>,
56    ) -> ClientIdentity {
57        Self::try_new(client_type, client_version).expect("client identity must be valid")
58    }
59
60    /// Creates a client identity after trimming and validating header values.
61    pub fn try_new(
62        client_type: impl Into<String>,
63        client_version: impl Into<String>,
64    ) -> Result<ClientIdentity, ClientIdentityError> {
65        let client_type = normalize_header_component("client_type", client_type.into())?;
66        let client_version = normalize_header_component("client_version", client_version.into())?;
67        Ok(Self {
68            client_type,
69            client_version,
70        })
71    }
72
73    /// Returns the default SDK identity for this crate version.
74    pub fn sdk() -> ClientIdentity {
75        ClientIdentity::new(SDK_CLIENT_TYPE, sdk_version())
76    }
77
78    /// Returns the Inline client type, such as `rust-sdk` or `cli`.
79    pub fn client_type(&self) -> &str {
80        &self.client_type
81    }
82
83    /// Returns the application version sent to Inline.
84    pub fn client_version(&self) -> &str {
85        &self.client_version
86    }
87}
88
89impl Default for ClientIdentity {
90    fn default() -> Self {
91        Self::sdk()
92    }
93}
94
95/// Login metadata sent with auth code and verification requests.
96#[must_use]
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct AuthMetadata {
99    device_id: String,
100    device_name: Option<String>,
101    client: ClientIdentity,
102}
103
104impl AuthMetadata {
105    /// Creates auth metadata for a durable device id and client identity.
106    pub fn new(device_id: impl Into<String>, client: ClientIdentity) -> Self {
107        Self {
108            device_id: device_id.into(),
109            device_name: None,
110            client,
111        }
112    }
113
114    /// Creates auth metadata using the default SDK client identity.
115    pub fn sdk(device_id: impl Into<String>) -> Self {
116        Self::new(device_id, ClientIdentity::sdk())
117    }
118
119    /// Attaches a human-readable device name.
120    pub fn with_device_name(mut self, device_name: impl Into<String>) -> Self {
121        let device_name = device_name.into().trim().to_string();
122        if !device_name.is_empty() {
123            self.device_name = Some(device_name);
124        }
125        self
126    }
127
128    /// Returns the durable device id.
129    pub fn device_id(&self) -> &str {
130        &self.device_id
131    }
132
133    /// Returns the optional human-readable device name.
134    pub fn device_name(&self) -> Option<&str> {
135        self.device_name.as_deref()
136    }
137
138    /// Returns the client identity used for auth requests.
139    pub fn client(&self) -> &ClientIdentity {
140        &self.client
141    }
142}
143
144/// Returns the version of the `inline-sdk` crate.
145pub fn sdk_version() -> &'static str {
146    env!("CARGO_PKG_VERSION")
147}
148
149/// Builds the default SDK user agent.
150pub fn user_agent() -> String {
151    user_agent_for(&ClientIdentity::sdk())
152}
153
154/// Builds a user agent for a specific client identity.
155pub fn user_agent_for(identity: &ClientIdentity) -> String {
156    format!("{}/{}", identity.client_type(), identity.client_version())
157}
158
159/// Returns a best-effort local device name.
160pub fn device_name() -> Option<String> {
161    hostname::get()
162        .ok()
163        .and_then(|name| name.into_string().ok())
164        .map(|name| name.trim().to_string())
165        .filter(|name| !name.is_empty())
166}
167
168/// Builds default Inline HTTP headers for the SDK identity.
169pub fn default_http_headers() -> HeaderMap {
170    default_http_headers_for(&ClientIdentity::sdk())
171}
172
173/// Builds Inline HTTP headers for a specific client identity.
174pub fn default_http_headers_for(identity: &ClientIdentity) -> HeaderMap {
175    try_default_http_headers_for(identity).expect("validated client identity must fit HTTP headers")
176}
177
178/// Builds Inline HTTP headers for a specific client identity.
179pub fn try_default_http_headers_for(
180    identity: &ClientIdentity,
181) -> Result<HeaderMap, ClientIdentityError> {
182    let mut headers = HeaderMap::new();
183    headers.insert(
184        CLIENT_TYPE_HEADER,
185        header_value("client_type", identity.client_type())?,
186    );
187    headers.insert(
188        CLIENT_VERSION_HEADER,
189        header_value("client_version", identity.client_version())?,
190    );
191    Ok(headers)
192}
193
194/// Builds a `reqwest` client builder configured with SDK identity headers.
195pub fn http_client_builder() -> reqwest::ClientBuilder {
196    reqwest::Client::builder()
197        .default_headers(default_http_headers())
198        .user_agent(user_agent())
199}
200
201/// Builds a `reqwest` client builder configured with a specific identity.
202pub fn http_client_builder_for(identity: &ClientIdentity) -> reqwest::ClientBuilder {
203    reqwest::Client::builder()
204        .default_headers(default_http_headers_for(identity))
205        .user_agent(user_agent_for(identity))
206}
207
208/// Returns a best-effort operating system version string for realtime init.
209pub fn current_os_version() -> Option<String> {
210    let mut cmd = match std::env::consts::OS {
211        "macos" => {
212            let mut cmd = Command::new("sw_vers");
213            cmd.arg("-productVersion");
214            cmd
215        }
216        "linux" => {
217            let mut cmd = Command::new("uname");
218            cmd.arg("-r");
219            cmd
220        }
221        "windows" => {
222            let mut cmd = Command::new("cmd");
223            cmd.args(["/C", "ver"]);
224            cmd
225        }
226        _ => return Some(std::env::consts::OS.to_string()),
227    };
228
229    let output = cmd.output().ok()?;
230    if !output.status.success() {
231        return Some(std::env::consts::OS.to_string());
232    }
233
234    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
235    if value.is_empty() {
236        return Some(std::env::consts::OS.to_string());
237    }
238
239    Some(value)
240}
241
242fn normalize_header_component(
243    field: &'static str,
244    value: String,
245) -> Result<String, ClientIdentityError> {
246    let value = value.trim().to_string();
247    if value.is_empty() {
248        return Err(ClientIdentityError::Empty { field });
249    }
250    HeaderValue::from_str(&value).map_err(|_| ClientIdentityError::InvalidHeaderValue { field })?;
251    Ok(value)
252}
253
254fn header_value(field: &'static str, value: &str) -> Result<HeaderValue, ClientIdentityError> {
255    HeaderValue::from_str(value).map_err(|_| ClientIdentityError::InvalidHeaderValue { field })
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use reqwest::header::USER_AGENT;
262
263    #[test]
264    fn default_headers_identify_sdk_without_user_agent() {
265        let headers = default_http_headers();
266        assert_eq!(
267            headers
268                .get(CLIENT_TYPE_HEADER)
269                .and_then(|value| value.to_str().ok()),
270            Some("rust-sdk")
271        );
272        assert_eq!(
273            headers
274                .get(CLIENT_VERSION_HEADER)
275                .and_then(|value| value.to_str().ok()),
276            Some(sdk_version())
277        );
278        assert!(headers.get(USER_AGENT).is_none());
279    }
280
281    #[test]
282    fn default_headers_can_use_custom_client_identity() {
283        let identity = ClientIdentity::new("my-agent", "0.1.0");
284        let headers = default_http_headers_for(&identity);
285        assert_eq!(
286            headers
287                .get(CLIENT_TYPE_HEADER)
288                .and_then(|value| value.to_str().ok()),
289            Some("my-agent")
290        );
291        assert_eq!(
292            headers
293                .get(CLIENT_VERSION_HEADER)
294                .and_then(|value| value.to_str().ok()),
295            Some("0.1.0")
296        );
297        assert!(headers.get(USER_AGENT).is_none());
298    }
299
300    #[test]
301    fn fallible_default_headers_can_use_custom_client_identity() {
302        let identity = ClientIdentity::new("my-agent", "0.1.0");
303        let headers = try_default_http_headers_for(&identity).unwrap();
304        assert_eq!(
305            headers
306                .get(CLIENT_TYPE_HEADER)
307                .and_then(|value| value.to_str().ok()),
308            Some("my-agent")
309        );
310        assert_eq!(
311            headers
312                .get(CLIENT_VERSION_HEADER)
313                .and_then(|value| value.to_str().ok()),
314            Some("0.1.0")
315        );
316    }
317
318    #[test]
319    fn user_agent_identifies_sdk_version() {
320        assert_eq!(user_agent(), format!("rust-sdk/{}", sdk_version()));
321    }
322
323    #[test]
324    fn user_agent_can_use_custom_client_identity() {
325        let identity = ClientIdentity::new("my-agent", "0.1.0");
326        assert_eq!(user_agent_for(&identity), "my-agent/0.1.0");
327    }
328
329    #[test]
330    fn http_client_builder_builds_with_sdk_metadata() {
331        assert!(http_client_builder().build().is_ok());
332    }
333
334    #[test]
335    fn http_client_builder_builds_with_custom_client_identity() {
336        let identity = ClientIdentity::new("my-agent", "0.1.0");
337        assert!(http_client_builder_for(&identity).build().is_ok());
338    }
339
340    #[test]
341    fn auth_metadata_stores_device_context() {
342        let identity = ClientIdentity::new("agent", "0.1.0");
343        let metadata = AuthMetadata::new("device-1", identity.clone()).with_device_name("mo-mac");
344        assert_eq!(metadata.device_id(), "device-1");
345        assert_eq!(metadata.device_name(), Some("mo-mac"));
346        assert_eq!(metadata.client(), &identity);
347    }
348
349    #[test]
350    fn auth_metadata_ignores_blank_device_name() {
351        let metadata = AuthMetadata::sdk("device-1").with_device_name("  ");
352        assert_eq!(metadata.device_id(), "device-1");
353        assert_eq!(metadata.device_name(), None);
354    }
355
356    #[test]
357    fn client_identity_rejects_empty_or_invalid_values() {
358        assert_eq!(
359            ClientIdentity::try_new("", "0.1.0").unwrap_err(),
360            ClientIdentityError::Empty {
361                field: "client_type"
362            }
363        );
364        assert_eq!(
365            ClientIdentity::try_new("agent", "0.1\n0").unwrap_err(),
366            ClientIdentityError::InvalidHeaderValue {
367                field: "client_version"
368            }
369        );
370    }
371
372    #[test]
373    fn device_name_is_never_empty() {
374        assert!(
375            device_name()
376                .as_deref()
377                .map(|name| !name.is_empty())
378                .unwrap_or(true)
379        );
380    }
381}