domain: add cross-cutting value objects (SystemId, DateTimeStamp, Checksum, StructuredData)
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
29
crates/domain/src/value_objects/checksum.rs
Normal file
29
crates/domain/src/value_objects/checksum.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
20
crates/domain/src/value_objects/date_time_stamp.rs
Normal file
20
crates/domain/src/value_objects/date_time_stamp.rs
Normal 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) }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
54
crates/domain/src/value_objects/structured_data.rs
Normal file
54
crates/domain/src/value_objects/structured_data.rs
Normal 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() }
|
||||
}
|
||||
24
crates/domain/src/value_objects/system_id.rs
Normal file
24
crates/domain/src/value_objects/system_id.rs
Normal 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) }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
Reference in New Issue
Block a user