Newtypes and broker refactor
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -16,7 +16,7 @@ postgres = [
|
||||
"tower-sessions-sqlx-store/postgres",
|
||||
"sqlx/postgres",
|
||||
]
|
||||
smart-features = ["notes-infra/smart-features", "dep:async-nats"]
|
||||
smart-features = ["notes-infra/smart-features", "notes-infra/broker-nats"]
|
||||
|
||||
[dependencies]
|
||||
notes-domain = { path = "../notes-domain" }
|
||||
@@ -36,7 +36,6 @@ tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
|
||||
password-auth = "1.0"
|
||||
time = "0.3"
|
||||
async-trait = "0.1.89"
|
||||
async-nats = { version = "0.39", optional = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
|
||||
@@ -10,7 +10,7 @@ use notes_domain::{Note, Tag};
|
||||
/// Request to create a new note
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateNoteRequest {
|
||||
#[validate(length(min = 1, max = 200, message = "Title must be 1-200 characters"))]
|
||||
#[validate(length(max = 200, message = "Title must be at most 200 characters"))]
|
||||
pub title: String,
|
||||
|
||||
#[serde(default)]
|
||||
@@ -29,7 +29,7 @@ pub struct CreateNoteRequest {
|
||||
/// Request to update an existing note (all fields optional)
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpdateNoteRequest {
|
||||
#[validate(length(min = 1, max = 200, message = "Title must be 1-200 characters"))]
|
||||
#[validate(length(max = 200, message = "Title must be at most 200 characters"))]
|
||||
pub title: Option<String>,
|
||||
|
||||
pub content: Option<String>,
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ mod auth;
|
||||
mod config;
|
||||
mod dto;
|
||||
mod error;
|
||||
#[cfg(feature = "smart-features")]
|
||||
mod nats_broker;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
@@ -81,13 +79,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// Connect to NATS (before creating services that depend on it)
|
||||
// Connect to message broker via factory
|
||||
#[cfg(feature = "smart-features")]
|
||||
let nats_client = {
|
||||
tracing::info!("Connecting to NATS: {}", config.broker_url);
|
||||
async_nats::connect(&config.broker_url)
|
||||
let message_broker = {
|
||||
use notes_infra::factory::{BrokerProvider, build_message_broker};
|
||||
tracing::info!("Connecting to message broker: {}", config.broker_url);
|
||||
let provider = BrokerProvider::Nats {
|
||||
url: config.broker_url.clone(),
|
||||
};
|
||||
build_message_broker(&provider)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("NATS connection failed: {}", e))?
|
||||
.map_err(|e| anyhow::anyhow!("Broker connection failed: {}", e))?
|
||||
};
|
||||
|
||||
// Create services
|
||||
@@ -95,9 +97,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Build NoteService with optional MessageBroker
|
||||
#[cfg(feature = "smart-features")]
|
||||
let note_service = {
|
||||
let broker = Arc::new(nats_broker::NatsMessageBroker::new(nats_client.clone()));
|
||||
Arc::new(NoteService::new(note_repo.clone(), tag_repo.clone()).with_message_broker(broker))
|
||||
let note_service = match message_broker {
|
||||
Some(broker) => Arc::new(
|
||||
NoteService::new(note_repo.clone(), tag_repo.clone()).with_message_broker(broker),
|
||||
),
|
||||
None => Arc::new(NoteService::new(note_repo.clone(), tag_repo.clone())),
|
||||
};
|
||||
#[cfg(not(feature = "smart-features"))]
|
||||
let note_service = Arc::new(NoteService::new(note_repo.clone(), tag_repo.clone()));
|
||||
@@ -115,8 +119,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
note_service,
|
||||
tag_service,
|
||||
user_service,
|
||||
#[cfg(feature = "smart-features")]
|
||||
nats_client,
|
||||
config.clone(),
|
||||
);
|
||||
|
||||
@@ -188,7 +190,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
async fn create_dev_user(pool: ¬es_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 +203,12 @@ async fn create_dev_user(pool: ¬es_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(),
|
||||
);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
//! NATS message broker adapter for domain MessageBroker port
|
||||
|
||||
use async_trait::async_trait;
|
||||
use notes_domain::{DomainError, DomainResult, MessageBroker, Note};
|
||||
|
||||
/// NATS adapter implementing the MessageBroker port
|
||||
pub struct NatsMessageBroker {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
impl NatsMessageBroker {
|
||||
pub fn new(client: async_nats::Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MessageBroker for NatsMessageBroker {
|
||||
async fn publish_note_updated(&self, note: &Note) -> DomainResult<()> {
|
||||
let payload = serde_json::to_vec(note).map_err(|e| {
|
||||
DomainError::RepositoryError(format!("Failed to serialize note: {}", e))
|
||||
})?;
|
||||
|
||||
self.client
|
||||
.publish("notes.updated", payload.into())
|
||||
.await
|
||||
.map_err(|e| DomainError::RepositoryError(format!("Failed to publish event: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ pub struct AppState {
|
||||
pub note_service: Arc<NoteService>,
|
||||
pub tag_service: Arc<TagService>,
|
||||
pub user_service: Arc<UserService>,
|
||||
#[cfg(feature = "smart-features")]
|
||||
pub nats_client: async_nats::Client,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
@@ -30,7 +28,6 @@ impl AppState {
|
||||
note_service: Arc<NoteService>,
|
||||
tag_service: Arc<TagService>,
|
||||
user_service: Arc<UserService>,
|
||||
#[cfg(feature = "smart-features")] nats_client: async_nats::Client,
|
||||
config: Config,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -42,8 +39,6 @@ impl AppState {
|
||||
note_service,
|
||||
tag_service,
|
||||
user_service,
|
||||
#[cfg(feature = "smart-features")]
|
||||
nats_client,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user