feat(domain): value objects, entities, port traits with tests
This commit is contained in:
2
crates/domain/src/entities/mod.rs
Normal file
2
crates/domain/src/entities/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod user;
|
||||
pub use user::User;
|
||||
17
crates/domain/src/entities/user.rs
Normal file
17
crates/domain/src/entities/user.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::value_objects::{Email, PasswordHash, Role, UserId};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
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() }
|
||||
}
|
||||
}
|
||||
13
crates/domain/src/errors.rs
Normal file
13
crates/domain/src/errors.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DomainError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
7
crates/domain/src/events.rs
Normal file
7
crates/domain/src/events.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DomainEvent {
|
||||
UserRegistered { user_id: Uuid },
|
||||
UserLoggedIn { user_id: Uuid },
|
||||
}
|
||||
5
crates/domain/src/lib.rs
Normal file
5
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod entities;
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod ports;
|
||||
pub mod value_objects;
|
||||
14
crates/domain/src/ports/auth.rs
Normal file
14
crates/domain/src/ports/auth.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::{errors::DomainError, value_objects::{PasswordHash, Role, UserId}};
|
||||
|
||||
#[async_trait]
|
||||
pub trait PasswordHasher: Send + Sync {
|
||||
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError>;
|
||||
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
#[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>;
|
||||
}
|
||||
5
crates/domain/src/ports/mod.rs
Normal file
5
crates/domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod auth;
|
||||
mod user_repo;
|
||||
|
||||
pub use auth::{PasswordHasher, TokenIssuer};
|
||||
pub use user_repo::UserRepository;
|
||||
10
crates/domain/src/ports/user_repo.rs
Normal file
10
crates/domain/src/ports/user_repo.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::{entities::User, errors::DomainError, value_objects::{Email, UserId}};
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: &UserId) -> 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>;
|
||||
}
|
||||
42
crates/domain/src/value_objects/email.rs
Normal file
42
crates/domain/src/value_objects/email.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Email(String);
|
||||
|
||||
impl Email {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let value = value.into().trim().to_lowercase();
|
||||
if value.is_empty() || !value.contains('@') {
|
||||
return Err(DomainError::Validation("Invalid email address".to_string()));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Email {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
9
crates/domain/src/value_objects/mod.rs
Normal file
9
crates/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod email;
|
||||
mod password;
|
||||
mod role;
|
||||
mod user_id;
|
||||
|
||||
pub use email::Email;
|
||||
pub use password::PasswordHash;
|
||||
pub use role::Role;
|
||||
pub use user_id::UserId;
|
||||
14
crates/domain/src/value_objects/password.rs
Normal file
14
crates/domain/src/value_objects/password.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
// Manual Debug — redacts hash to prevent it appearing in logs
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PasswordHash(String);
|
||||
|
||||
impl std::fmt::Debug for PasswordHash {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("PasswordHash").field(&"[redacted]").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordHash {
|
||||
pub fn from_hash(hash: String) -> Self { Self(hash) }
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
23
crates/domain/src/value_objects/role.rs
Normal file
23
crates/domain/src/value_objects/role.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
#[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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
22
crates/domain/src/value_objects/user_id.rs
Normal file
22
crates/domain/src/value_objects/user_id.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
#[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