domain: add cross-cutting value objects (SystemId, DateTimeStamp, Checksum, StructuredData)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -460,6 +460,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::{errors::DomainError, ports::TokenIssuer, value_objects::{Role, UserId}};
|
use domain::{errors::DomainError, ports::TokenIssuer, value_objects::SystemId};
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
@@ -30,7 +29,7 @@ impl JwtTokenIssuer {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TokenIssuer for JwtTokenIssuer {
|
impl TokenIssuer for JwtTokenIssuer {
|
||||||
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError> {
|
async fn issue(&self, user_id: &SystemId, role: &str) -> Result<String, DomainError> {
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: user_id.to_string(),
|
sub: user_id.to_string(),
|
||||||
role: role.to_string(),
|
role: role.to_string(),
|
||||||
@@ -40,14 +39,12 @@ impl TokenIssuer for JwtTokenIssuer {
|
|||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> {
|
async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError> {
|
||||||
let data = decode::<Claims>(token, &self.decoding_key, &Validation::default())
|
let data = decode::<Claims>(token, &self.decoding_key, &Validation::default())
|
||||||
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".to_string()))?;
|
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".to_string()))?;
|
||||||
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
|
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
|
||||||
.map_err(|_| DomainError::Unauthorized("Invalid token subject".to_string()))?;
|
.map_err(|_| DomainError::Unauthorized("Invalid token subject".to_string()))?;
|
||||||
let role = Role::from_str(&data.claims.role)
|
Ok((SystemId::from_uuid(uuid), data.claims.role))
|
||||||
.map_err(|_| DomainError::Unauthorized("Invalid role in token".to_string()))?;
|
|
||||||
Ok((UserId::from_uuid(uuid), role))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,11 +55,11 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn issue_and_verify_roundtrip() {
|
async fn issue_and_verify_roundtrip() {
|
||||||
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
|
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
|
||||||
let user_id = UserId::new();
|
let user_id = SystemId::new();
|
||||||
let token = issuer.issue(&user_id, &Role::User).await.unwrap();
|
let token = issuer.issue(&user_id, "user").await.unwrap();
|
||||||
let (verified_id, verified_role) = issuer.verify(&token).await.unwrap();
|
let (verified_id, verified_role) = issuer.verify(&token).await.unwrap();
|
||||||
assert_eq!(verified_id, user_id);
|
assert_eq!(verified_id, user_id);
|
||||||
assert_eq!(verified_role, Role::User);
|
assert_eq!(verified_role, "user");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ use domain::{
|
|||||||
entities::User,
|
entities::User,
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::UserRepository,
|
ports::UserRepository,
|
||||||
value_objects::{Email, PasswordHash, Role, UserId},
|
value_objects::{Email, PasswordHash, SystemId},
|
||||||
};
|
};
|
||||||
use std::str::FromStr;
|
|
||||||
use crate::db::PgPool;
|
use crate::db::PgPool;
|
||||||
|
|
||||||
pub struct PostgresUserRepository {
|
pub struct PostgresUserRepository {
|
||||||
@@ -18,9 +17,9 @@ impl PostgresUserRepository {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserRepository for PostgresUserRepository {
|
impl UserRepository for PostgresUserRepository {
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<User>, DomainError> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
"SELECT id, email, password_hash, role, created_at FROM users WHERE id = $1",
|
"SELECT id, email, password_hash, created_at FROM users WHERE id = $1",
|
||||||
*id.as_uuid()
|
*id.as_uuid()
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -28,10 +27,9 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
row.map(|r| Ok(User {
|
row.map(|r| Ok(User {
|
||||||
id: UserId::from_uuid(r.id),
|
id: SystemId::from_uuid(r.id),
|
||||||
email: Email::new(r.email)?,
|
email: Email::new(r.email)?,
|
||||||
password_hash: PasswordHash::from_hash(r.password_hash),
|
password_hash: PasswordHash::from_hash(r.password_hash),
|
||||||
role: Role::from_str(&r.role).map_err(DomainError::Internal)?,
|
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
}))
|
}))
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -39,7 +37,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
"SELECT id, email, password_hash, role, created_at FROM users WHERE email = $1",
|
"SELECT id, email, password_hash, created_at FROM users WHERE email = $1",
|
||||||
email.as_str()
|
email.as_str()
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -47,10 +45,9 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
row.map(|r| Ok(User {
|
row.map(|r| Ok(User {
|
||||||
id: UserId::from_uuid(r.id),
|
id: SystemId::from_uuid(r.id),
|
||||||
email: Email::new(r.email)?,
|
email: Email::new(r.email)?,
|
||||||
password_hash: PasswordHash::from_hash(r.password_hash),
|
password_hash: PasswordHash::from_hash(r.password_hash),
|
||||||
role: Role::from_str(&r.role).map_err(DomainError::Internal)?,
|
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
}))
|
}))
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -58,16 +55,14 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT INTO users (id, email, password_hash, role, created_at)
|
"INSERT INTO users (id, email, password_hash, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
email = EXCLUDED.email,
|
email = EXCLUDED.email,
|
||||||
password_hash = EXCLUDED.password_hash,
|
password_hash = EXCLUDED.password_hash",
|
||||||
role = EXCLUDED.role",
|
|
||||||
*user.id.as_uuid(),
|
*user.id.as_uuid(),
|
||||||
user.email.as_str(),
|
user.email.as_str(),
|
||||||
user.password_hash.as_str(),
|
user.password_hash.as_str(),
|
||||||
user.role.to_string(),
|
|
||||||
user.created_at
|
user.created_at
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -76,7 +71,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
sqlx::query!("DELETE FROM users WHERE id = $1", *id.as_uuid())
|
sqlx::query!("DELETE FROM users WHERE id = $1", *id.as_uuid())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use uuid::Uuid;
|
|||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub role: String,
|
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ impl UserResponse {
|
|||||||
Self {
|
Self {
|
||||||
id: *user.id.as_uuid(),
|
id: *user.id.as_uuid(),
|
||||||
email: user.email.to_string(),
|
email: user.email.to_string(),
|
||||||
role: user.role.to_string(),
|
|
||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use domain::{
|
|||||||
entities::User,
|
entities::User,
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{PasswordHasher, TokenIssuer, UserRepository},
|
ports::{PasswordHasher, TokenIssuer, UserRepository},
|
||||||
value_objects::{Email, PasswordHash, Role, UserId},
|
value_objects::{Email, PasswordHash, SystemId},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct InMemoryUserRepository {
|
pub struct InMemoryUserRepository {
|
||||||
@@ -28,7 +28,7 @@ impl Default for InMemoryUserRepository {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserRepository for InMemoryUserRepository {
|
impl UserRepository for InMemoryUserRepository {
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<User>, DomainError> {
|
||||||
Ok(self.users.lock().await.get(&id.to_string()).cloned())
|
Ok(self.users.lock().await.get(&id.to_string()).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ impl UserRepository for InMemoryUserRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
self.users.lock().await.remove(&id.to_string());
|
self.users.lock().await.remove(&id.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -65,15 +65,15 @@ pub struct StubTokenIssuer;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TokenIssuer for StubTokenIssuer {
|
impl TokenIssuer for StubTokenIssuer {
|
||||||
async fn issue(&self, user_id: &UserId, _role: &Role) -> Result<String, DomainError> {
|
async fn issue(&self, user_id: &SystemId, _role: &str) -> Result<String, DomainError> {
|
||||||
Ok(format!("token:{user_id}"))
|
Ok(format!("token:{user_id}"))
|
||||||
}
|
}
|
||||||
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> {
|
async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError> {
|
||||||
let id_str = token.strip_prefix("token:").ok_or_else(|| {
|
let id_str = token.strip_prefix("token:").ok_or_else(|| {
|
||||||
DomainError::Unauthorized("Invalid stub token".to_string())
|
DomainError::Unauthorized("Invalid stub token".to_string())
|
||||||
})?;
|
})?;
|
||||||
let uuid = uuid::Uuid::parse_str(id_str)
|
let uuid = uuid::Uuid::parse_str(id_str)
|
||||||
.map_err(|_| DomainError::Unauthorized("Bad UUID in stub token".to_string()))?;
|
.map_err(|_| DomainError::Unauthorized("Bad UUID in stub token".to_string()))?;
|
||||||
Ok((UserId::from_uuid(uuid), Role::User))
|
Ok((SystemId::from_uuid(uuid), "user".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::UserId};
|
use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::SystemId};
|
||||||
|
|
||||||
pub struct GetProfile {
|
pub struct GetProfile {
|
||||||
repo: Arc<dyn UserRepository>,
|
repo: Arc<dyn UserRepository>,
|
||||||
@@ -8,7 +8,7 @@ pub struct GetProfile {
|
|||||||
impl GetProfile {
|
impl GetProfile {
|
||||||
pub fn new(repo: Arc<dyn UserRepository>) -> Self { Self { repo } }
|
pub fn new(repo: Arc<dyn UserRepository>) -> Self { Self { repo } }
|
||||||
|
|
||||||
pub async fn execute(&self, user_id: &UserId) -> Result<User, DomainError> {
|
pub async fn execute(&self, user_id: &SystemId) -> Result<User, DomainError> {
|
||||||
self.repo.find_by_id(user_id).await?
|
self.repo.find_by_id(user_id).await?
|
||||||
.ok_or_else(|| DomainError::NotFound(format!("User {user_id} not found")))
|
.ok_or_else(|| DomainError::NotFound(format!("User {user_id} not found")))
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ mod tests {
|
|||||||
async fn get_profile_returns_not_found() {
|
async fn get_profile_returns_not_found() {
|
||||||
let repo = Arc::new(InMemoryUserRepository::new());
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
let uc = GetProfile::new(repo);
|
let uc = GetProfile::new(repo);
|
||||||
let result = uc.execute(&UserId::new()).await;
|
let result = uc.execute(&SystemId::new()).await;
|
||||||
assert!(matches!(result, Err(DomainError::NotFound(_))));
|
assert!(matches!(result, Err(DomainError::NotFound(_))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ impl LoginUser {
|
|||||||
if !valid {
|
if !valid {
|
||||||
return Err(DomainError::Unauthorized("Invalid credentials".to_string()));
|
return Err(DomainError::Unauthorized("Invalid credentials".to_string()));
|
||||||
}
|
}
|
||||||
let token = self.issuer.issue(&user.id, &user.role).await?;
|
let token = self.issuer.issue(&user.id, "user").await?;
|
||||||
Ok((user, token))
|
Ok((user, token))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use domain::{
|
|||||||
entities::User,
|
entities::User,
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{PasswordHasher, UserRepository},
|
ports::{PasswordHasher, UserRepository},
|
||||||
value_objects::{Email, UserId},
|
value_objects::{Email, SystemId},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct RegisterUser {
|
pub struct RegisterUser {
|
||||||
@@ -25,7 +25,7 @@ impl RegisterUser {
|
|||||||
return Err(DomainError::Conflict(format!("Email {} is already registered", email.as_str())));
|
return Err(DomainError::Conflict(format!("Email {} is already registered", email.as_str())));
|
||||||
}
|
}
|
||||||
let hash = self.hasher.hash(password).await?;
|
let hash = self.hasher.hash(password).await?;
|
||||||
let user = User::new(UserId::new(), email, hash);
|
let user = User::new(SystemId::new(), email, hash);
|
||||||
self.repo.save(&user).await?;
|
self.repo.save(&user).await?;
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,6 @@ thiserror = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
use chrono::{DateTime, Utc};
|
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)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: UserId,
|
pub id: SystemId,
|
||||||
pub email: Email,
|
pub email: Email,
|
||||||
pub password_hash: PasswordHash,
|
pub password_hash: PasswordHash,
|
||||||
pub role: Role,
|
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub fn new(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
|
pub fn new(id: SystemId, email: Email, password_hash: PasswordHash) -> Self {
|
||||||
Self { id, email, password_hash, role: Role::User, created_at: Utc::now() }
|
Self { id, email, password_hash, created_at: Utc::now() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use crate::{errors::DomainError, value_objects::{PasswordHash, Role, UserId}};
|
use crate::{errors::DomainError, value_objects::{PasswordHash, SystemId}};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait PasswordHasher: Send + Sync {
|
pub trait PasswordHasher: Send + Sync {
|
||||||
@@ -9,6 +9,6 @@ pub trait PasswordHasher: Send + Sync {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait TokenIssuer: Send + Sync {
|
pub trait TokenIssuer: Send + Sync {
|
||||||
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError>;
|
async fn issue(&self, user_id: &SystemId, role: &str) -> Result<String, DomainError>;
|
||||||
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError>;
|
async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use async_trait::async_trait;
|
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]
|
#[async_trait]
|
||||||
pub trait UserRepository: Send + Sync {
|
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 find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||||
async fn save(&self, user: &User) -> Result<(), 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 email;
|
||||||
mod password;
|
mod password;
|
||||||
mod role;
|
mod structured_data;
|
||||||
mod user_id;
|
mod system_id;
|
||||||
|
|
||||||
|
pub use checksum::Checksum;
|
||||||
|
pub use date_time_stamp::DateTimeStamp;
|
||||||
pub use email::Email;
|
pub use email::Email;
|
||||||
pub use password::PasswordHash;
|
pub use password::PasswordHash;
|
||||||
pub use role::Role;
|
pub use structured_data::{MetadataValue, StructuredData};
|
||||||
pub use user_id::UserId;
|
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) }
|
|
||||||
}
|
|
||||||
1
crates/domain/tests/domain_tests.rs
Normal file
1
crates/domain/tests/domain_tests.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mod value_objects;
|
||||||
27
crates/domain/tests/value_objects/checksum.rs
Normal file
27
crates/domain/tests/value_objects/checksum.rs
Normal 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));
|
||||||
|
}
|
||||||
30
crates/domain/tests/value_objects/date_time_stamp.rs
Normal file
30
crates/domain/tests/value_objects/date_time_stamp.rs
Normal 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);
|
||||||
|
}
|
||||||
4
crates/domain/tests/value_objects/mod.rs
Normal file
4
crates/domain/tests/value_objects/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mod checksum;
|
||||||
|
mod date_time_stamp;
|
||||||
|
mod structured_data;
|
||||||
|
mod system_id;
|
||||||
45
crates/domain/tests/value_objects/structured_data.rs
Normal file
45
crates/domain/tests/value_objects/structured_data.rs
Normal 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());
|
||||||
|
}
|
||||||
31
crates/domain/tests/value_objects/system_id.rs
Normal file
31
crates/domain/tests/value_objects/system_id.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::value_objects::{Role, UserId};
|
use domain::value_objects::SystemId;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub struct JwtClaims {
|
pub struct JwtClaims {
|
||||||
pub user_id: UserId,
|
pub user_id: SystemId,
|
||||||
pub role: Role,
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for JwtClaims {
|
impl FromRequestParts<AppState> for JwtClaims {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pub async fn register(
|
|||||||
ValidatedJson(req): ValidatedJson<RegisterRequest>,
|
ValidatedJson(req): ValidatedJson<RegisterRequest>,
|
||||||
) -> Result<(StatusCode, Json<AuthResponse>), AppError> {
|
) -> Result<(StatusCode, Json<AuthResponse>), AppError> {
|
||||||
let user = state.register_uc.execute(&req.email, &req.password).await?;
|
let user = state.register_uc.execute(&req.email, &req.password).await?;
|
||||||
let token = state.token_issuer.issue(&user.id, &user.role).await.map_err(AppError::from)?;
|
let token = state.token_issuer.issue(&user.id, "user").await.map_err(AppError::from)?;
|
||||||
Ok((StatusCode::CREATED, Json(AuthResponse { token, user: UserResponse::from_domain(&user) })))
|
Ok((StatusCode::CREATED, Json(AuthResponse { token, user: UserResponse::from_domain(&user) })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user