use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use uuid::Uuid;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const CLIENT_VERSION_KEY: &str = "telemetryClientVersion";
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Signal {
pub received_at: DateTime<Utc>,
#[serde(rename = "appID")]
pub app_id: String,
pub client_user: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "type")]
pub signal_type: String,
pub payload: Vec<String>,
pub is_test_mode: String,
#[serde(rename = "floatValue")]
#[serde(skip_serializing_if = "Option::is_none")]
pub float_value: Option<f64>,
}
#[derive(Debug)]
pub struct TelemetryDeck {
url: String,
pub app_id: String,
pub namespace: Option<String>,
pub salt: Option<String>,
pub default_params: HashMap<String, String>,
pub session_id: String,
}
impl TelemetryDeck {
#[must_use]
pub fn new(app_id: &str) -> Self {
Self::new_with_config(app_id, None, None, HashMap::new())
}
#[must_use]
pub fn new_with_config(
app_id: &str,
namespace: Option<String>,
salt: Option<String>,
params: HashMap<String, String>,
) -> Self {
TelemetryDeck {
url: String::from("https://nom.telemetrydeck.com"),
app_id: app_id.to_string(),
namespace,
salt,
default_params: Self::adding_params(
¶ms,
Some(HashMap::from([(
CLIENT_VERSION_KEY.to_string(),
VERSION.to_string(),
)])),
),
session_id: Uuid::new_v4().to_string(),
}
}
pub fn reset_session(&mut self, new_session_id: Option<String>) {
self.session_id = new_session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
}
pub(crate) fn create_signal(
&self,
signal_type: &str,
client_user: Option<&str>,
payload: Option<HashMap<String, String>>,
is_test_mode: Option<bool>,
float_value: Option<f64>,
) -> Signal {
let params = Self::adding_params(&self.default_params, payload);
let payload = Self::encoded_payload(params);
let client_user = client_user.map_or_else(
|| "rust".to_string(),
|u| {
let user_with_salt = if let Some(salt) = &self.salt {
format!("{}{}", u, salt)
} else {
u.to_string()
};
let mut sha256 = Sha256::new();
sha256.update(user_with_salt.as_bytes());
format!("{:x}", sha256.finalize())
},
);
Signal {
received_at: Utc::now(),
app_id: self.app_id.clone(),
client_user,
session_id: self.session_id.clone(),
signal_type: signal_type.to_string(),
payload,
is_test_mode: is_test_mode.unwrap_or(false).to_string(),
float_value,
}
}
pub(crate) fn build_url(&self) -> String {
if let Some(namespace) = &self.namespace {
format!("{}/v2/namespace/{}/", self.url, namespace)
} else {
format!("{}/v2/", self.url)
}
}
fn adding_params(
params1: &HashMap<String, String>,
params2: Option<HashMap<String, String>>,
) -> HashMap<String, String> {
let mut result = params1.clone();
if let Some(params) = params2 {
result.extend(params);
}
result
}
fn encoded_payload(params: HashMap<String, String>) -> Vec<String> {
params
.into_iter()
.map(|(k, v)| format!("{}:{}", k.replace(':', "_"), v))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::TelemetryDeck;
use std::collections::HashMap;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[test]
fn create_signal_without_user() {
let sut = TelemetryDeck::new("1234");
let result = sut.create_signal("signal_type", None, None, None, None);
assert_eq!(result.client_user, "rust".to_string());
assert_eq!(result.signal_type, "signal_type".to_string());
assert_eq!(result.app_id, "1234".to_string());
assert_eq!(result.is_test_mode, "false".to_string());
assert_eq!(
result.payload,
vec![format!("telemetryClientVersion:{VERSION}")]
);
assert_eq!(result.float_value, None);
}
#[test]
fn create_signal_with_user_is_hashed() {
let sut = TelemetryDeck::new("1234");
let result = sut.create_signal("signal_type", Some("clientUser"), None, None, None);
assert_eq!(
result.client_user,
"6721870580401922549fe8fdb09a064dba5b8792fa018d3bd9ffa90fe37a0149".to_string()
);
assert_eq!(result.signal_type, "signal_type".to_string());
assert_eq!(result.app_id, "1234".to_string());
assert_eq!(result.is_test_mode, "false".to_string());
assert_eq!(
result.payload,
vec![format!("telemetryClientVersion:{VERSION}")]
);
}
#[test]
fn create_signal_with_user_and_salt_is_hashed() {
let sut = TelemetryDeck::new_with_config(
"1234",
None,
Some("someSalt".to_string()),
HashMap::new(),
);
let result = sut.create_signal("signal_type", Some("clientUser"), None, None, None);
assert_eq!(
result.client_user,
"ffdd613ce521b2e94b8931bdadffd96857f6abbde6c0ee1fcf0b76127fbb9e5a".to_string()
);
}
#[test]
fn create_signal_with_float_value() {
let sut = TelemetryDeck::new("1234");
let result = sut.create_signal("signal_type", None, None, None, Some(42.5));
assert_eq!(result.float_value, Some(42.5));
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"floatValue\":42.5"));
}
#[test]
fn create_signal_without_float_value_omits_field() {
let sut = TelemetryDeck::new("1234");
let result = sut.create_signal("signal_type", None, None, None, None);
assert_eq!(result.float_value, None);
let json = serde_json::to_string(&result).unwrap();
assert!(!json.contains("floatValue"));
}
#[test]
fn build_url_without_namespace() {
let sut = TelemetryDeck::new("1234");
assert_eq!(sut.build_url(), "https://nom.telemetrydeck.com/v2/");
}
#[test]
fn build_url_with_namespace() {
let sut = TelemetryDeck::new_with_config(
"1234",
Some("my-namespace".to_string()),
None,
HashMap::new(),
);
assert_eq!(
sut.build_url(),
"https://nom.telemetrydeck.com/v2/namespace/my-namespace/"
);
}
#[test]
fn reset_session() {
let mut sut = TelemetryDeck::new("1234");
let session1 = sut.session_id.clone();
sut.reset_session(None);
let session2 = sut.session_id.clone();
assert_ne!(session1, session2);
}
#[test]
fn reset_session_with_specific_id() {
let mut sut = TelemetryDeck::new("1234");
sut.reset_session(Some("my session".to_string()));
let session2 = sut.session_id.clone();
assert_eq!(session2, "my session".to_string());
}
}