domain: add cross-cutting value objects (SystemId, DateTimeStamp, Checksum, StructuredData)

This commit is contained in:
2026-05-31 03:16:28 +02:00
parent f9cb142c3b
commit 3571c94bec
28 changed files with 320 additions and 122 deletions

View File

@@ -11,3 +11,6 @@ thiserror = { workspace = true }
async-trait = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@@ -1,17 +1,16 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{Email, PasswordHash, Role, UserId};
use crate::value_objects::{Email, PasswordHash, SystemId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
pub id: UserId,
pub id: SystemId,
pub email: Email,
pub password_hash: PasswordHash,
pub role: Role,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
Self { id, email, password_hash, role: Role::User, created_at: Utc::now() }
pub fn new(id: SystemId, email: Email, password_hash: PasswordHash) -> Self {
Self { id, email, password_hash, created_at: Utc::now() }
}
}

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use crate::{errors::DomainError, value_objects::{PasswordHash, Role, UserId}};
use crate::{errors::DomainError, value_objects::{PasswordHash, SystemId}};
#[async_trait]
pub trait PasswordHasher: Send + Sync {
@@ -9,6 +9,6 @@ pub trait PasswordHasher: Send + Sync {
#[async_trait]
pub trait TokenIssuer: Send + Sync {
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError>;
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError>;
async fn issue(&self, user_id: &SystemId, role: &str) -> Result<String, DomainError>;
async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError>;
}

View File

@@ -1,10 +1,10 @@
use async_trait::async_trait;
use crate::{entities::User, errors::DomainError, value_objects::{Email, UserId}};
use crate::{entities::User, errors::DomainError, value_objects::{Email, SystemId}};
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn find_by_id(&self, id: &SystemId) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn delete(&self, id: &UserId) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -0,0 +1,29 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Checksum(String);
impl Checksum {
pub fn new(hex: impl Into<String>) -> Result<Self, DomainError> {
let hex = hex.into().to_lowercase();
if hex.len() != 64 {
return Err(DomainError::Validation(
format!("Checksum must be 64 hex characters, got {}", hex.len()),
));
}
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(DomainError::Validation(
"Checksum contains non-hex characters".to_string(),
));
}
Ok(Self(hex))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl std::fmt::Display for Checksum {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

View File

@@ -0,0 +1,20 @@
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct DateTimeStamp(DateTime<Utc>);
impl DateTimeStamp {
pub fn now() -> Self { Self(Utc::now()) }
pub fn from_datetime(dt: DateTime<Utc>) -> Self { Self(dt) }
pub fn as_datetime(&self) -> &DateTime<Utc> { &self.0 }
}
impl std::fmt::Display for DateTimeStamp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.to_rfc3339())
}
}
impl From<DateTime<Utc>> for DateTimeStamp {
fn from(dt: DateTime<Utc>) -> Self { Self(dt) }
}

View File

@@ -21,22 +21,3 @@ impl std::fmt::Display for Email {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() { assert!(Email::new("").is_err()); }
#[test]
fn rejects_no_at() { assert!(Email::new("notanemail").is_err()); }
#[test]
fn accepts_valid() { assert!(Email::new("user@example.com").is_ok()); }
#[test]
fn lowercases_and_trims() {
let email = Email::new(" User@Example.Com ").unwrap();
assert_eq!(email.as_str(), "user@example.com");
}
}

View File

@@ -1,9 +1,13 @@
mod checksum;
mod date_time_stamp;
mod email;
mod password;
mod role;
mod user_id;
mod structured_data;
mod system_id;
pub use checksum::Checksum;
pub use date_time_stamp::DateTimeStamp;
pub use email::Email;
pub use password::PasswordHash;
pub use role::Role;
pub use user_id::UserId;
pub use structured_data::{MetadataValue, StructuredData};
pub use system_id::SystemId;

View File

@@ -1,23 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role { User, Admin }
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::User => write!(f, "user"),
Role::Admin => write!(f, "admin"),
}
}
}
impl std::str::FromStr for Role {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(Role::User),
"admin" => Ok(Role::Admin),
other => Err(format!("Unknown role: {other}")),
}
}
}

View File

@@ -0,0 +1,54 @@
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum MetadataValue {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
Null,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StructuredData(HashMap<String, MetadataValue>);
impl StructuredData {
pub fn new() -> Self { Self(HashMap::new()) }
pub fn insert(&mut self, key: impl Into<String>, value: MetadataValue) {
self.0.insert(key.into(), value);
}
pub fn get(&self, key: &str) -> Option<&MetadataValue> {
self.0.get(key)
}
pub fn get_string(&self, key: &str) -> Option<&str> {
match self.0.get(key) {
Some(MetadataValue::String(s)) => Some(s.as_str()),
_ => None,
}
}
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.0.keys()
}
pub fn is_empty(&self) -> bool { self.0.is_empty() }
pub fn len(&self) -> usize { self.0.len() }
pub fn merge_from(&mut self, other: StructuredData) {
self.0.extend(other.0);
}
pub fn remove(&mut self, key: &str) -> Option<MetadataValue> {
self.0.remove(key)
}
pub fn inner(&self) -> &HashMap<String, MetadataValue> { &self.0 }
}
impl Default for StructuredData {
fn default() -> Self { Self::new() }
}

View File

@@ -0,0 +1,24 @@
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct SystemId(Uuid);
impl SystemId {
pub fn new() -> Self { Self(Uuid::new_v4()) }
pub fn from_uuid(id: Uuid) -> Self { Self(id) }
pub fn as_uuid(&self) -> &Uuid { &self.0 }
}
impl Default for SystemId {
fn default() -> Self { Self::new() }
}
impl std::fmt::Display for SystemId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Uuid> for SystemId {
fn from(id: Uuid) -> Self { Self(id) }
}

View File

@@ -1,22 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct UserId(uuid::Uuid);
impl UserId {
pub fn new() -> Self { Self(uuid::Uuid::new_v4()) }
pub fn from_uuid(id: uuid::Uuid) -> Self { Self(id) }
pub fn as_uuid(&self) -> &uuid::Uuid { &self.0 }
}
impl Default for UserId {
fn default() -> Self { Self::new() }
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<uuid::Uuid> for UserId {
fn from(id: uuid::Uuid) -> Self { Self(id) }
}

View File

@@ -0,0 +1 @@
mod value_objects;

View File

@@ -0,0 +1,27 @@
use domain::value_objects::Checksum;
#[test]
fn accepts_valid_sha256() {
let hex = "a".repeat(64);
assert!(Checksum::new(&hex).is_ok());
}
#[test]
fn rejects_short() {
let hex = "a".repeat(63);
assert!(Checksum::new(&hex).is_err());
}
#[test]
fn rejects_non_hex() {
let mut hex = "a".repeat(63);
hex.push('g');
assert!(Checksum::new(&hex).is_err());
}
#[test]
fn normalizes_to_lowercase() {
let hex = "A".repeat(64);
let cs = Checksum::new(&hex).unwrap();
assert_eq!(cs.as_str(), "a".repeat(64));
}

View File

@@ -0,0 +1,30 @@
use chrono::Utc;
use domain::value_objects::DateTimeStamp;
#[test]
fn now_is_recent() {
let before = Utc::now();
let ts = DateTimeStamp::now();
let after = Utc::now();
assert!(*ts.as_datetime() >= before);
assert!(*ts.as_datetime() <= after);
}
#[test]
fn ordering() {
let a = DateTimeStamp::from_datetime(
chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z").unwrap().into(),
);
let b = DateTimeStamp::from_datetime(
chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z").unwrap().into(),
);
assert!(a < b);
}
#[test]
fn serde_roundtrip() {
let ts = DateTimeStamp::now();
let json = serde_json::to_string(&ts).unwrap();
let back: DateTimeStamp = serde_json::from_str(&json).unwrap();
assert_eq!(ts, back);
}

View File

@@ -0,0 +1,4 @@
mod checksum;
mod date_time_stamp;
mod structured_data;
mod system_id;

View File

@@ -0,0 +1,45 @@
use domain::value_objects::{MetadataValue, StructuredData};
#[test]
fn insert_and_get() {
let mut sd = StructuredData::new();
sd.insert("key", MetadataValue::String("value".into()));
assert_eq!(sd.get_string("key"), Some("value"));
assert_eq!(sd.len(), 1);
assert!(!sd.is_empty());
}
#[test]
fn merge_from_overwrites() {
let mut a = StructuredData::new();
a.insert("k", MetadataValue::Integer(1));
let mut b = StructuredData::new();
b.insert("k", MetadataValue::Integer(2));
a.merge_from(b);
assert!(matches!(a.get("k"), Some(MetadataValue::Integer(2))));
}
#[test]
fn serde_roundtrip() {
let mut sd = StructuredData::new();
sd.insert("s", MetadataValue::String("hello".into()));
sd.insert("i", MetadataValue::Integer(42));
sd.insert("f", MetadataValue::Float(3.14));
sd.insert("b", MetadataValue::Boolean(true));
sd.insert("n", MetadataValue::Null);
let json = serde_json::to_string(&sd).unwrap();
let back: StructuredData = serde_json::from_str(&json).unwrap();
assert_eq!(sd, back);
}
#[test]
fn remove_works() {
let mut sd = StructuredData::new();
sd.insert("k", MetadataValue::Integer(1));
let removed = sd.remove("k");
assert!(matches!(removed, Some(MetadataValue::Integer(1))));
assert!(sd.is_empty());
}

View File

@@ -0,0 +1,31 @@
use domain::value_objects::SystemId;
use uuid::Uuid;
#[test]
fn unique_generation() {
let a = SystemId::new();
let b = SystemId::new();
assert_ne!(a, b);
}
#[test]
fn uuid_roundtrip() {
let uuid = Uuid::new_v4();
let id = SystemId::from_uuid(uuid);
assert_eq!(*id.as_uuid(), uuid);
}
#[test]
fn display_matches_uuid() {
let uuid = Uuid::new_v4();
let id = SystemId::from_uuid(uuid);
assert_eq!(id.to_string(), uuid.to_string());
}
#[test]
fn serde_roundtrip() {
let id = SystemId::new();
let json = serde_json::to_string(&id).unwrap();
let back: SystemId = serde_json::from_str(&json).unwrap();
assert_eq!(id, back);
}