use chrono::{DateTime, SecondsFormat, TimeZone, Utc};
use serde::Serialize;
use serde_json::{Map, Value};
use crate::error::{Error, Result};
pub const DEFAULT_GROUP_TYPE: &str = "organization";
const MAX_EVENT_ID_LENGTH: usize = 64;
#[derive(Debug, Clone)]
pub struct Event {
event_type: String,
identity: String,
data: Option<Serialized>,
identity_data: Option<Serialized>,
groups: Vec<Group>,
event_id: Option<String>,
timestamp: Option<DateTime<Utc>>,
source: Option<String>,
}
impl Event {
pub fn new(event_type: impl Into<String>, identity: impl Into<String>) -> Self {
Event {
event_type: event_type.into(),
identity: identity.into(),
data: None,
identity_data: None,
groups: Vec::new(),
event_id: None,
timestamp: None,
source: None,
}
}
pub fn data(mut self, data: impl Serialize) -> Self {
self.data = Some(Serialized::from(data));
self
}
pub fn identity_data(mut self, data: impl Serialize) -> Self {
self.identity_data = Some(Serialized::from(data));
self
}
pub fn group(mut self, group: Group) -> Self {
self.groups.push(group);
self
}
pub fn groups(mut self, groups: impl IntoIterator<Item = Group>) -> Self {
self.groups.extend(groups);
self
}
pub fn event_id(mut self, event_id: impl Into<String>) -> Self {
self.event_id = Some(event_id.into());
self
}
pub fn timestamp<Tz: TimeZone>(mut self, timestamp: DateTime<Tz>) -> Self {
self.timestamp = Some(timestamp.with_timezone(&Utc));
self
}
pub fn source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
pub(crate) fn into_body(self) -> Result<Value> {
if self.event_type.is_empty() {
return Err(invalid("The event type is required."));
}
if self.identity.is_empty() {
return Err(invalid(
"identity is required. A server-side event has no device signature, so the \
identity is the only thing tying the event to a user.",
));
}
let groups = groups_body(self.groups)?;
let data = object_or_empty(self.data, "data")?;
let identity_data = object_or_empty(self.identity_data, "identity_data")?;
let event_id = self
.event_id
.unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
if event_id.starts_with("dregs-") {
return Err(invalid(
"Event ids starting with 'dregs-' are reserved for Dregs itself.",
));
}
if event_id.chars().count() > MAX_EVENT_ID_LENGTH {
return Err(invalid("Event ids cannot be longer than 64 characters."));
}
let mut body = Map::new();
body.insert("id".into(), event_id.into());
body.insert("type".into(), self.event_type.into());
body.insert("data".into(), Value::Object(data));
body.insert(
"identity".into(),
serde_json::json!({ "id": self.identity, "data": identity_data }),
);
body.insert(
"source".into(),
self.source.unwrap_or_else(|| "rust-sdk".into()).into(),
);
if !groups.is_empty() {
body.insert("groups".into(), Value::Array(groups));
}
if let Some(timestamp) = self.timestamp {
body.insert(
"timestamp".into(),
timestamp
.to_rfc3339_opts(SecondsFormat::AutoSi, true)
.into(),
);
}
Ok(Value::Object(body))
}
}
#[derive(Debug, Clone)]
pub struct Group {
group_type: String,
id: String,
data: Option<Serialized>,
}
impl Group {
pub fn new(group_type: impl Into<String>, id: impl Into<String>) -> Self {
Group {
group_type: group_type.into(),
id: id.into(),
data: None,
}
}
pub fn organization(id: impl Into<String>) -> Self {
Group::new(DEFAULT_GROUP_TYPE, id)
}
pub fn data(mut self, data: impl Serialize) -> Self {
self.data = Some(Serialized::from(data));
self
}
}
impl Group {
pub(crate) fn into_create_body(self) -> Result<Value> {
if self.id.is_empty() {
return Err(invalid("A group id is required."));
}
let group_type = if self.group_type.is_empty() {
DEFAULT_GROUP_TYPE.to_owned()
} else {
self.group_type
};
let attributes = object_or_empty(self.data, "A group's data")?;
Ok(serde_json::json!({ "type": group_type, "id": self.id, "attributes": attributes }))
}
}
fn groups_body(groups: Vec<Group>) -> Result<Vec<Value>> {
let mut resolved = Vec::with_capacity(groups.len());
let mut seen: Vec<String> = Vec::with_capacity(groups.len());
for group in groups {
if group.id.is_empty() {
return Err(invalid("Each group needs an id."));
}
let group_type = if group.group_type.is_empty() {
DEFAULT_GROUP_TYPE.to_owned()
} else {
group.group_type
};
let folded = group_type.to_lowercase();
if seen.contains(&folded) {
return Err(invalid(format!(
"Only one group of each type can go with an event; '{group_type}' appears twice."
)));
}
let data = object_or_empty(group.data, "A group's data")?;
seen.push(folded);
resolved.push(serde_json::json!({ "type": group_type, "id": group.id, "data": data }));
}
Ok(resolved)
}
#[derive(Debug, Clone)]
pub(crate) struct Serialized(std::result::Result<Value, String>);
impl Serialized {
pub(crate) fn from(data: impl Serialize) -> Self {
Serialized(serde_json::to_value(data).map_err(|error| error.to_string()))
}
}
pub(crate) fn object_or_empty(data: Option<Serialized>, name: &str) -> Result<Map<String, Value>> {
match data.map(|serialized| serialized.0) {
None | Some(Ok(Value::Null)) => Ok(Map::new()),
Some(Ok(Value::Object(map))) => Ok(map),
Some(Ok(_)) => Err(invalid(format!(
"{name} must serialize to a JSON object, such as json!({{\"name\": \"Payments\"}})."
))),
Some(Err(error)) => Err(invalid(format!("{name} could not be serialized: {error}"))),
}
}
fn invalid(message: impl Into<String>) -> Error {
Error::InvalidArgument(message.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_whole_second_timestamp_has_no_fraction() {
let body = Event::new("user.signup", "user_12345")
.timestamp(Utc.with_ymd_and_hms(2026, 9, 21, 14, 22, 9).unwrap())
.into_body()
.unwrap();
assert_eq!(body["timestamp"], "2026-09-21T14:22:09Z");
}
#[test]
fn a_generated_id_is_32_hex_characters() {
let body = Event::new("user.signup", "user_12345").into_body().unwrap();
let id = body["id"].as_str().unwrap();
assert_eq!(id.len(), 32);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
}