feat: Introduce domain value objects for email, password, note title, and tags, integrating them across API, domain, and infrastructure for enhanced type safety and validation.

This commit is contained in:
2025-12-31 01:25:20 +01:00
parent 146d775f02
commit 93170d17dc
14 changed files with 881 additions and 211 deletions

View File

@@ -68,7 +68,7 @@ impl From<Tag> for TagResponse {
fn from(tag: Tag) -> Self {
Self {
id: tag.id,
name: tag.name,
name: tag.name.into_inner(), // Convert TagName to String
}
}
}
@@ -91,7 +91,7 @@ impl From<Note> for NoteResponse {
fn from(note: Note) -> Self {
Self {
id: note.id,
title: note.title,
title: note.title_str().to_string(), // Convert Option<NoteTitle> to String
content: note.content,
color: note.color,
is_pinned: note.is_pinned,
@@ -160,7 +160,7 @@ impl From<notes_domain::NoteVersion> for NoteVersionResponse {
Self {
id: version.id,
note_id: version.note_id,
title: version.title,
title: version.title.unwrap_or_default(), // Convert Option<String> to String
content: version.content,
created_at: version.created_at,
}

View File

@@ -188,7 +188,7 @@ async fn main() -> anyhow::Result<()> {
}
async fn create_dev_user(pool: &notes_infra::db::DatabasePool) -> anyhow::Result<()> {
use notes_domain::User;
use notes_domain::{Email, User};
use notes_infra::factory::build_user_repository;
use password_auth::generate_hash;
use uuid::Uuid;
@@ -201,10 +201,12 @@ async fn create_dev_user(pool: &notes_infra::db::DatabasePool) -> anyhow::Result
let dev_user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
if user_repo.find_by_id(dev_user_id).await?.is_none() {
let hash = generate_hash("password");
let dev_email = Email::try_from("dev@localhost.com")
.map_err(|e| anyhow::anyhow!("Invalid dev email: {}", e))?;
let user = User::with_id(
dev_user_id,
"dev|local",
"dev@localhost.com",
dev_email,
Some(hash),
chrono::Utc::now(),
);

View File

@@ -4,7 +4,7 @@ use axum::{Json, extract::State, http::StatusCode};
use axum_login::AuthSession;
use validator::Validate;
use notes_domain::User;
use notes_domain::{Email, User};
use password_auth::generate_hash;
use crate::auth::{AuthBackend, AuthUser, Credentials};
@@ -43,9 +43,12 @@ pub async fn register(
// Hash password
let password_hash = generate_hash(&payload.password);
// Create use
// For local registration, we use email as subject
let user = User::new_local(&payload.email, &password_hash);
// Parse email string to Email newtype
let email = Email::try_from(payload.email)
.map_err(|e| ApiError::validation(format!("Invalid email: {}", e)))?;
// Create user - for local registration, we use email as subject
let user = User::new_local(email, &password_hash);
state.user_repo.save(&user).await.map_err(ApiError::from)?;
@@ -108,7 +111,7 @@ pub async fn me(
Ok(Json(crate::dto::UserResponse {
id: user.0.id,
email: user.0.email.clone(),
email: user.0.email_str().to_string(), // Convert Email to String
created_at: user.0.created_at,
}))
}

View File

@@ -10,7 +10,10 @@ use uuid::Uuid;
use validator::Validate;
use axum_login::AuthUser;
use notes_domain::{CreateNoteRequest as DomainCreateNote, UpdateNoteRequest as DomainUpdateNote};
use notes_domain::{
CreateNoteRequest as DomainCreateNote, NoteTitle, TagName,
UpdateNoteRequest as DomainUpdateNote,
};
use crate::auth::AuthBackend;
use crate::dto::{CreateNoteRequest, ListNotesQuery, NoteResponse, SearchQuery, UpdateNoteRequest};
@@ -71,11 +74,30 @@ pub async fn create_note(
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
// Parse title into NoteTitle (optional - empty string becomes None)
let title: Option<NoteTitle> = if payload.title.trim().is_empty() {
None
} else {
Some(
NoteTitle::try_from(payload.title)
.map_err(|e| ApiError::validation(format!("Invalid title: {}", e)))?,
)
};
// Parse tags into TagName values
let tags: Vec<TagName> = payload
.tags
.into_iter()
.map(|s| {
TagName::try_from(s).map_err(|e| ApiError::validation(format!("Invalid tag: {}", e)))
})
.collect::<Result<Vec<_>, _>>()?;
let domain_req = DomainCreateNote {
user_id,
title: payload.title,
title,
content: payload.content,
tags: payload.tags,
tags,
color: payload.color,
is_pinned: payload.is_pinned,
};
@@ -126,15 +148,40 @@ pub async fn update_note(
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
// Parse optional title - Some(string) -> Some(Some(NoteTitle)) or Some(None) for empty
let title: Option<Option<NoteTitle>> = match payload.title {
Some(t) if t.trim().is_empty() => Some(None), // Set title to None
Some(t) => {
Some(Some(NoteTitle::try_from(t).map_err(|e| {
ApiError::validation(format!("Invalid title: {}", e))
})?))
}
None => None, // Don't update title
};
// Parse optional tags
let tags: Option<Vec<TagName>> = match payload.tags {
Some(tag_strings) => Some(
tag_strings
.into_iter()
.map(|s| {
TagName::try_from(s)
.map_err(|e| ApiError::validation(format!("Invalid tag: {}", e)))
})
.collect::<Result<Vec<_>, _>>()?,
),
None => None,
};
let domain_req = DomainUpdateNote {
id,
user_id,
title: payload.title,
title,
content: payload.content,
is_pinned: payload.is_pinned,
is_archived: payload.is_archived,
color: payload.color,
tags: payload.tags,
tags,
};
let note = state.note_service.update_note(domain_req).await?;

View File

@@ -9,6 +9,8 @@ use axum_login::{AuthSession, AuthUser};
use uuid::Uuid;
use validator::Validate;
use notes_domain::TagName;
use crate::auth::AuthBackend;
use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse};
use crate::error::{ApiError, ApiResult};
@@ -51,7 +53,11 @@ pub async fn create_tag(
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
let tag = state.tag_service.create_tag(user_id, &payload.name).await?;
// Parse string to TagName at API boundary
let tag_name = TagName::try_from(payload.name)
.map_err(|e| ApiError::validation(format!("Invalid tag name: {}", e)))?;
let tag = state.tag_service.create_tag(user_id, tag_name).await?;
Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
}
@@ -75,10 +81,11 @@ pub async fn rename_tag(
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
let tag = state
.tag_service
.rename_tag(id, user_id, &payload.name)
.await?;
// Parse string to TagName at API boundary
let new_name = TagName::try_from(payload.name)
.map_err(|e| ApiError::validation(format!("Invalid tag name: {}", e)))?;
let tag = state.tag_service.rename_tag(id, user_id, new_name).await?;
Ok(Json(TagResponse::from(tag)))
}