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]
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 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 pub fn sdk(device_id: impl Into<String>) -> Self {
116 Self::new(device_id, ClientIdentity::sdk())
117 }
118
119 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 pub fn device_id(&self) -> &str {
130 &self.device_id
131 }
132
133 pub fn device_name(&self) -> Option<&str> {
135 self.device_name.as_deref()
136 }
137
138 pub fn client(&self) -> &ClientIdentity {
140 &self.client
141 }
142}
143
144pub fn sdk_version() -> &'static str {
146 env!("CARGO_PKG_VERSION")
147}
148
149pub fn user_agent() -> String {
151 user_agent_for(&ClientIdentity::sdk())
152}
153
154pub fn user_agent_for(identity: &ClientIdentity) -> String {
156 format!("{}/{}", identity.client_type(), identity.client_version())
157}
158
159pub 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
168pub fn default_http_headers() -> HeaderMap {
170 default_http_headers_for(&ClientIdentity::sdk())
171}
172
173pub 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
178pub 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
194pub fn http_client_builder() -> reqwest::ClientBuilder {
196 reqwest::Client::builder()
197 .default_headers(default_http_headers())
198 .user_agent(user_agent())
199}
200
201pub 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
208pub 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}