feat(domain): value objects, entities, port traits with tests
This commit is contained in:
11
crates/domain/Cargo.toml
Normal file
11
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "domain"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
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