inline_sdk/
client_info.rs1use reqwest::header::{HeaderMap, HeaderValue};
4use std::process::Command;
5use thiserror::Error;
6
7pub const SDK_CLIENT_TYPE: &str = "rust-sdk";
9pub const CLIENT_TYPE_HEADER: &str = "x-inline-client-type";
11pub const CLIENT_VERSION_HEADER: &str = "x-inline-client-version";
13
14#[derive(Clone, Debug, Error, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum ClientIdentityError {
18 #[error("{field} cannot be empty")]
20 Empty {
21 field: &'static str,
23 },
24 #[error("{field} contains characters that are invalid in HTTP headers")]
26 InvalidHeaderValue {
27 field: &'static str,
29 },
30}
31
32#[must_use]
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct ClientIdentity {
39 client_type: String,
40 client_version: String,
41}
42
43impl ClientIdentity {
44 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 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 pub fn sdk() -> ClientIdentity {
75 ClientIdentity::new(SDK_CLIENT_TYPE, sdk_version())
76 }
77
78 pub fn client_type(&self) -> &str {
80 &self.client_type
81 }
82
83 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#[must_use]
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct AuthMetadata {
103 device_id: String,
104 device_name: Option<String>,
105 client: ClientIdentity,
106}
107
108impl AuthMetadata {
109 pub fn new(device_id: impl Into<String>, client: ClientIdentity) -> Self {
111 Self {
112 device_id: device_id.into(),
113 device_name: None,
114 client,
115 }
116 }
117
118 pub fn sdk(device_id: impl Into<String>) -> Self {
120 Self::new(device_id, ClientIdentity::sdk())
121 }
122
123 pub fn with_device_name(mut self, device_name: impl Into<String>) -> Self {
125 let device_name = device_name.into().trim().to_string();
126 if !device_name.is_empty() {
127 self.device_name = Some(device_name);
128 }
129 self
130 }
131
132 pub fn device_id(&self) -> &str {
134 &self.device_id
135 }
136
137 pub fn device_name(&self) -> Option<&str> {
139 self.device_name.as_deref()
140 }
141
142 pub fn client(&self) -> &ClientIdentity {
144 &self.client
145 }
146}
147
148pub fn sdk_version() -> &'static str {
150 env!("CARGO_PKG_VERSION")
151}
152
153pub fn user_agent() -> String {
155 user_agent_for(&ClientIdentity::sdk())
156}
157
158pub fn user_agent_for(identity: &ClientIdentity) -> String {
160 format!("{}/{}", identity.client_type(), identity.client_version())
161}
162
163pub fn device_name() -> Option<String> {
165 hostname::get()
166 .ok()
167 .and_then(|name| name.into_string().ok())
168 .map(|name| name.trim().to_string())
169 .filter(|name| !name.is_empty())
170}
171
172pub fn default_http_headers() -> HeaderMap {
174 default_http_headers_for(&ClientIdentity::sdk())
175}
176
177pub fn default_http_headers_for(identity: &ClientIdentity) -> HeaderMap {
179 try_default_http_headers_for(identity).expect("validated client identity must fit HTTP headers")
180}
181
182pub fn try_default_http_headers_for(
184 identity: &ClientIdentity,
185) -> Result<HeaderMap, ClientIdentityError> {
186 let mut headers = HeaderMap::new();
187 headers.insert(
188 CLIENT_TYPE_HEADER,
189 header_value("client_type", identity.client_type())?,
190 );
191 headers.insert(
192 CLIENT_VERSION_HEADER,
193 header_value("client_version", identity.client_version())?,
194 );
195 Ok(headers)
196}
197
198pub fn http_client_builder() -> reqwest::ClientBuilder {
200 reqwest::Client::builder()
201 .default_headers(default_http_headers())
202 .user_agent(user_agent())
203}
204
205pub fn http_client_builder_for(identity: &ClientIdentity) -> reqwest::ClientBuilder {
207 reqwest::Client::builder()
208 .default_headers(default_http_headers_for(identity))
209 .user_agent(user_agent_for(identity))
210}
211
212pub fn current_os_version() -> Option<String> {
214 let mut cmd = match std::env::consts::OS {
215 "macos" => {
216 let mut cmd = Command::new("sw_vers");
217 cmd.arg("-productVersion");
218 cmd
219 }
220 "linux" => {
221 let mut cmd = Command::new("uname");
222 cmd.arg("-r");
223 cmd
224 }
225 "windows" => {
226 let mut cmd = Command::new("cmd");
227 cmd.args(["/C", "ver"]);
228 cmd
229 }
230 _ => return Some(std::env::consts::OS.to_string()),
231 };
232
233 let output = cmd.output().ok()?;
234 if !output.status.success() {
235 return Some(std::env::consts::OS.to_string());
236 }
237
238 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
239 if value.is_empty() {
240 return Some(std::env::consts::OS.to_string());
241 }
242
243 Some(value)
244}
245
246fn normalize_header_component(
247 field: &'static str,
248 value: String,
249) -> Result<String, ClientIdentityError> {
250 let value = value.trim().to_string();
251 if value.is_empty() {
252 return Err(ClientIdentityError::Empty { field });
253 }
254 HeaderValue::from_str(&value).map_err(|_| ClientIdentityError::InvalidHeaderValue { field })?;
255 Ok(value)
256}
257
258fn header_value(field: &'static str, value: &str) -> Result<HeaderValue, ClientIdentityError> {
259 HeaderValue::from_str(value).map_err(|_| ClientIdentityError::InvalidHeaderValue { field })
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use reqwest::header::USER_AGENT;
266
267 #[test]
268 fn default_headers_identify_sdk_without_user_agent() {
269 let headers = default_http_headers();
270 assert_eq!(
271 headers
272 .get(CLIENT_TYPE_HEADER)
273 .and_then(|value| value.to_str().ok()),
274 Some("rust-sdk")
275 );
276 assert_eq!(
277 headers
278 .get(CLIENT_VERSION_HEADER)
279 .and_then(|value| value.to_str().ok()),
280 Some(sdk_version())
281 );
282 assert!(headers.get(USER_AGENT).is_none());
283 }
284
285 #[test]
286 fn default_headers_can_use_custom_client_identity() {
287 let identity = ClientIdentity::new("my-agent", "0.1.0");
288 let headers = default_http_headers_for(&identity);
289 assert_eq!(
290 headers
291 .get(CLIENT_TYPE_HEADER)
292 .and_then(|value| value.to_str().ok()),
293 Some("my-agent")
294 );
295 assert_eq!(
296 headers
297 .get(CLIENT_VERSION_HEADER)
298 .and_then(|value| value.to_str().ok()),
299 Some("0.1.0")
300 );
301 assert!(headers.get(USER_AGENT).is_none());
302 }
303
304 #[test]
305 fn fallible_default_headers_can_use_custom_client_identity() {
306 let identity = ClientIdentity::new("my-agent", "0.1.0");
307 let headers = try_default_http_headers_for(&identity).unwrap();
308 assert_eq!(
309 headers
310 .get(CLIENT_TYPE_HEADER)
311 .and_then(|value| value.to_str().ok()),
312 Some("my-agent")
313 );
314 assert_eq!(
315 headers
316 .get(CLIENT_VERSION_HEADER)
317 .and_then(|value| value.to_str().ok()),
318 Some("0.1.0")
319 );
320 }
321
322 #[test]
323 fn user_agent_identifies_sdk_version() {
324 assert_eq!(user_agent(), format!("rust-sdk/{}", sdk_version()));
325 }
326
327 #[test]
328 fn user_agent_can_use_custom_client_identity() {
329 let identity = ClientIdentity::new("my-agent", "0.1.0");
330 assert_eq!(user_agent_for(&identity), "my-agent/0.1.0");
331 }
332
333 #[test]
334 fn http_client_builder_builds_with_sdk_metadata() {
335 assert!(http_client_builder().build().is_ok());
336 }
337
338 #[test]
339 fn http_client_builder_builds_with_custom_client_identity() {
340 let identity = ClientIdentity::new("my-agent", "0.1.0");
341 assert!(http_client_builder_for(&identity).build().is_ok());
342 }
343
344 #[test]
345 fn auth_metadata_stores_device_context() {
346 let identity = ClientIdentity::new("agent", "0.1.0");
347 let metadata = AuthMetadata::new("device-1", identity.clone()).with_device_name("mo-mac");
348 assert_eq!(metadata.device_id(), "device-1");
349 assert_eq!(metadata.device_name(), Some("mo-mac"));
350 assert_eq!(metadata.client(), &identity);
351 }
352
353 #[test]
354 fn auth_metadata_ignores_blank_device_name() {
355 let metadata = AuthMetadata::sdk("device-1").with_device_name(" ");
356 assert_eq!(metadata.device_id(), "device-1");
357 assert_eq!(metadata.device_name(), None);
358 }
359
360 #[test]
361 fn client_identity_rejects_empty_or_invalid_values() {
362 assert_eq!(
363 ClientIdentity::try_new("", "0.1.0").unwrap_err(),
364 ClientIdentityError::Empty {
365 field: "client_type"
366 }
367 );
368 assert_eq!(
369 ClientIdentity::try_new("agent", "0.1\n0").unwrap_err(),
370 ClientIdentityError::InvalidHeaderValue {
371 field: "client_version"
372 }
373 );
374 }
375
376 #[test]
377 fn device_name_is_never_empty() {
378 assert!(
379 device_name()
380 .as_deref()
381 .map(|name| !name.is_empty())
382 .unwrap_or(true)
383 );
384 }
385}