use base64::prelude::*;
use chrono::{DateTime, SubsecRound, Utc};
use rand::prelude::*;
use serde::{Deserialize, Serialize};
use std::{fmt::Display, str::FromStr};
use thiserror::Error;
pub const LOG_ID_PREFIX: &str = "log_";
pub const DEFAULT_ENTROPY_LEN: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct LogId {
created_at: DateTime<Utc>,
random: Vec<u8>,
}
impl LogId {
pub fn new() -> Self {
Self::with_entropy_len(DEFAULT_ENTROPY_LEN)
}
pub fn with_entropy_len(entropy_len: usize) -> Self {
let created_at = Utc::now().trunc_subsecs(6);
let mut random = vec![0; entropy_len];
thread_rng().fill_bytes(&mut random);
Self {
created_at,
random: random.to_vec(),
}
}
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
}
impl Default for LogId {
fn default() -> Self {
Self::new()
}
}
impl Display for LogId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut buf: Vec<u8> = Vec::with_capacity(8 + self.random.len());
buf.extend(self.created_at.timestamp_micros().to_le_bytes());
buf.extend(&self.random);
write!(f, "{LOG_ID_PREFIX}{}", BASE64_URL_SAFE_NO_PAD.encode(buf))
}
}
impl From<LogId> for String {
fn from(value: LogId) -> Self {
value.to_string()
}
}
#[derive(Debug, Error, Clone)]
pub enum LogIdParsingError {
#[error("string is too short")]
TooShort,
#[error("error decoding string")]
Decode(#[from] base64::DecodeError),
#[error("timestamp {0} is out of acceptable range")]
TimestampOutOfRange(i64),
}
impl FromStr for LogId {
type Err = LogIdParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() < LOG_ID_PREFIX.len() {
return Err(LogIdParsingError::TooShort);
}
let decoded = BASE64_URL_SAFE_NO_PAD.decode(&s[LOG_ID_PREFIX.len()..])?;
if decoded.len() < 8 {
return Err(LogIdParsingError::TooShort);
}
let timestamp = i64::from_le_bytes(decoded[0..8].try_into().expect("checked length above"));
let created_at = DateTime::<Utc>::from_timestamp_micros(timestamp)
.ok_or(LogIdParsingError::TimestampOutOfRange(timestamp))?;
Ok(LogId {
created_at,
random: decoded[8..].to_vec(),
})
}
}
impl TryFrom<String> for LogId {
type Error = LogIdParsingError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let log_id = LogId::new();
let s = log_id.to_string();
assert!(s.starts_with(LOG_ID_PREFIX));
assert_eq!(s.len(), 26);
let parsed: LogId = s.parse().unwrap();
assert_eq!(parsed, log_id);
}
#[test]
fn with_entropy_len() {
let log_id = LogId::with_entropy_len(16);
let s = log_id.to_string();
assert!(s.starts_with(LOG_ID_PREFIX));
assert_eq!(s.len(), 36);
let parsed: LogId = s.parse().unwrap();
assert_eq!(parsed, log_id);
}
#[test]
fn empty_too_short() {
assert!(matches!(
"".parse::<LogId>(),
Err(LogIdParsingError::TooShort)
));
}
#[test]
fn prefix_only_too_short() {
assert!(matches!(
LOG_ID_PREFIX.parse::<LogId>(),
Err(LogIdParsingError::TooShort)
));
}
#[test]
fn invalid_base64_decode_error() {
let s = format!("{LOG_ID_PREFIX}🤓");
let result: Result<LogId, LogIdParsingError> = s.parse::<LogId>();
assert!(matches!(result, Err(LogIdParsingError::Decode(_))));
}
#[test]
fn serializes_as_string() {
let log_id = LogId::new();
let serialized = serde_json::to_string(&log_id).unwrap();
let expected = format!("\"{}\"", log_id.to_string());
assert_eq!(serialized, expected);
}
#[test]
fn deserializes_from_string() {
let log_id = LogId::new();
let serialized = format!("\"{}\"", log_id.to_string());
let deserialized: LogId = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, log_id);
}
}