use reqwest::header::{HeaderMap, HeaderValue};
use std::process::Command;
use thiserror::Error;
pub const SDK_CLIENT_TYPE: &str = "rust-sdk";
pub const CLIENT_TYPE_HEADER: &str = "x-inline-client-type";
pub const CLIENT_VERSION_HEADER: &str = "x-inline-client-version";
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClientIdentityError {
#[error("{field} cannot be empty")]
Empty {
field: &'static str,
},
#[error("{field} contains characters that are invalid in HTTP headers")]
InvalidHeaderValue {
field: &'static str,
},
}
#[must_use]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientIdentity {
client_type: String,
client_version: String,
}
impl ClientIdentity {
pub fn new(
client_type: impl Into<String>,
client_version: impl Into<String>,
) -> ClientIdentity {
Self::try_new(client_type, client_version).expect("client identity must be valid")
}
pub fn try_new(
client_type: impl Into<String>,
client_version: impl Into<String>,
) -> Result<ClientIdentity, ClientIdentityError> {
let client_type = normalize_header_component("client_type", client_type.into())?;
let client_version = normalize_header_component("client_version", client_version.into())?;
Ok(Self {
client_type,
client_version,
})
}
pub fn sdk() -> ClientIdentity {
ClientIdentity::new(SDK_CLIENT_TYPE, sdk_version())
}
pub fn client_type(&self) -> &str {
&self.client_type
}
pub fn client_version(&self) -> &str {
&self.client_version
}
}
impl Default for ClientIdentity {
fn default() -> Self {
Self::sdk()
}
}
#[must_use]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMetadata {
device_id: String,
device_name: Option<String>,
client: ClientIdentity,
}
impl AuthMetadata {
pub fn new(device_id: impl Into<String>, client: ClientIdentity) -> Self {
Self {
device_id: device_id.into(),
device_name: None,
client,
}
}
pub fn sdk(device_id: impl Into<String>) -> Self {
Self::new(device_id, ClientIdentity::sdk())
}
pub fn with_device_name(mut self, device_name: impl Into<String>) -> Self {
let device_name = device_name.into().trim().to_string();
if !device_name.is_empty() {
self.device_name = Some(device_name);
}
self
}
pub fn device_id(&self) -> &str {
&self.device_id
}
pub fn device_name(&self) -> Option<&str> {
self.device_name.as_deref()
}
pub fn client(&self) -> &ClientIdentity {
&self.client
}
}
pub fn sdk_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn user_agent() -> String {
user_agent_for(&ClientIdentity::sdk())
}
pub fn user_agent_for(identity: &ClientIdentity) -> String {
format!("{}/{}", identity.client_type(), identity.client_version())
}
pub fn device_name() -> Option<String> {
hostname::get()
.ok()
.and_then(|name| name.into_string().ok())
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
}
pub fn default_http_headers() -> HeaderMap {
default_http_headers_for(&ClientIdentity::sdk())
}
pub fn default_http_headers_for(identity: &ClientIdentity) -> HeaderMap {
try_default_http_headers_for(identity).expect("validated client identity must fit HTTP headers")
}
pub fn try_default_http_headers_for(
identity: &ClientIdentity,
) -> Result<HeaderMap, ClientIdentityError> {
let mut headers = HeaderMap::new();
headers.insert(
CLIENT_TYPE_HEADER,
header_value("client_type", identity.client_type())?,
);
headers.insert(
CLIENT_VERSION_HEADER,
header_value("client_version", identity.client_version())?,
);
Ok(headers)
}
pub fn http_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder()
.default_headers(default_http_headers())
.user_agent(user_agent())
}
pub fn http_client_builder_for(identity: &ClientIdentity) -> reqwest::ClientBuilder {
reqwest::Client::builder()
.default_headers(default_http_headers_for(identity))
.user_agent(user_agent_for(identity))
}
pub fn current_os_version() -> Option<String> {
let mut cmd = match std::env::consts::OS {
"macos" => {
let mut cmd = Command::new("sw_vers");
cmd.arg("-productVersion");
cmd
}
"linux" => {
let mut cmd = Command::new("uname");
cmd.arg("-r");
cmd
}
"windows" => {
let mut cmd = Command::new("cmd");
cmd.args(["/C", "ver"]);
cmd
}
_ => return Some(std::env::consts::OS.to_string()),
};
let output = cmd.output().ok()?;
if !output.status.success() {
return Some(std::env::consts::OS.to_string());
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
return Some(std::env::consts::OS.to_string());
}
Some(value)
}
fn normalize_header_component(
field: &'static str,
value: String,
) -> Result<String, ClientIdentityError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(ClientIdentityError::Empty { field });
}
HeaderValue::from_str(&value).map_err(|_| ClientIdentityError::InvalidHeaderValue { field })?;
Ok(value)
}
fn header_value(field: &'static str, value: &str) -> Result<HeaderValue, ClientIdentityError> {
HeaderValue::from_str(value).map_err(|_| ClientIdentityError::InvalidHeaderValue { field })
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::USER_AGENT;
#[test]
fn default_headers_identify_sdk_without_user_agent() {
let headers = default_http_headers();
assert_eq!(
headers
.get(CLIENT_TYPE_HEADER)
.and_then(|value| value.to_str().ok()),
Some("rust-sdk")
);
assert_eq!(
headers
.get(CLIENT_VERSION_HEADER)
.and_then(|value| value.to_str().ok()),
Some(sdk_version())
);
assert!(headers.get(USER_AGENT).is_none());
}
#[test]
fn default_headers_can_use_custom_client_identity() {
let identity = ClientIdentity::new("my-agent", "0.1.0");
let headers = default_http_headers_for(&identity);
assert_eq!(
headers
.get(CLIENT_TYPE_HEADER)
.and_then(|value| value.to_str().ok()),
Some("my-agent")
);
assert_eq!(
headers
.get(CLIENT_VERSION_HEADER)
.and_then(|value| value.to_str().ok()),
Some("0.1.0")
);
assert!(headers.get(USER_AGENT).is_none());
}
#[test]
fn fallible_default_headers_can_use_custom_client_identity() {
let identity = ClientIdentity::new("my-agent", "0.1.0");
let headers = try_default_http_headers_for(&identity).unwrap();
assert_eq!(
headers
.get(CLIENT_TYPE_HEADER)
.and_then(|value| value.to_str().ok()),
Some("my-agent")
);
assert_eq!(
headers
.get(CLIENT_VERSION_HEADER)
.and_then(|value| value.to_str().ok()),
Some("0.1.0")
);
}
#[test]
fn user_agent_identifies_sdk_version() {
assert_eq!(user_agent(), format!("rust-sdk/{}", sdk_version()));
}
#[test]
fn user_agent_can_use_custom_client_identity() {
let identity = ClientIdentity::new("my-agent", "0.1.0");
assert_eq!(user_agent_for(&identity), "my-agent/0.1.0");
}
#[test]
fn http_client_builder_builds_with_sdk_metadata() {
assert!(http_client_builder().build().is_ok());
}
#[test]
fn http_client_builder_builds_with_custom_client_identity() {
let identity = ClientIdentity::new("my-agent", "0.1.0");
assert!(http_client_builder_for(&identity).build().is_ok());
}
#[test]
fn auth_metadata_stores_device_context() {
let identity = ClientIdentity::new("agent", "0.1.0");
let metadata = AuthMetadata::new("device-1", identity.clone()).with_device_name("mo-mac");
assert_eq!(metadata.device_id(), "device-1");
assert_eq!(metadata.device_name(), Some("mo-mac"));
assert_eq!(metadata.client(), &identity);
}
#[test]
fn auth_metadata_ignores_blank_device_name() {
let metadata = AuthMetadata::sdk("device-1").with_device_name(" ");
assert_eq!(metadata.device_id(), "device-1");
assert_eq!(metadata.device_name(), None);
}
#[test]
fn client_identity_rejects_empty_or_invalid_values() {
assert_eq!(
ClientIdentity::try_new("", "0.1.0").unwrap_err(),
ClientIdentityError::Empty {
field: "client_type"
}
);
assert_eq!(
ClientIdentity::try_new("agent", "0.1\n0").unwrap_err(),
ClientIdentityError::InvalidHeaderValue {
field: "client_version"
}
);
}
#[test]
fn device_name_is_never_empty() {
assert!(
device_name()
.as_deref()
.map(|name| !name.is_empty())
.unwrap_or(true)
);
}
}