This commit is contained in:
2025-12-23 02:15:25 +01:00
commit 39b28c7f3b
120 changed files with 15045 additions and 0 deletions

1
notes-api/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

46
notes-api/Cargo.toml Normal file
View File

@@ -0,0 +1,46 @@
[package]
name = "notes-api"
version = "0.1.0"
edition = "2024"
[dependencies]
notes-domain = { path = "../notes-domain" }
notes-infra = { path = "../notes-infra" }
# Web framework
axum = { version = "0.8.8", features = ["macros"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
# Authentication
axum-login = "0.18"
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
password-auth = "1.0"
time = "0.3"
async-trait = "0.1.89"
# Async runtime
tokio = { version = "1.48.0", features = ["full"] }
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
# Validation
validator = { version = "0.20", features = ["derive"] }
# Error handling
thiserror = "2.0.17"
anyhow = "1.0"
# Utilities
chrono = { version = "0.4.42", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
# Database
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"] }

87
notes-api/src/auth.rs Normal file
View File

@@ -0,0 +1,87 @@
//! Authentication logic using axum-login
use std::sync::Arc;
use axum_login::{AuthnBackend, UserId};
use password_auth::verify_password;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::ApiError;
use notes_domain::{User, UserRepository};
/// Wrapper around domain User to implement AuthUser
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthUser(pub User);
impl axum_login::AuthUser for AuthUser {
type Id = Uuid;
fn id(&self) -> Self::Id {
self.0.id
}
fn session_auth_hash(&self) -> &[u8] {
// Use password hash to invalidate sessions if password changes
self.0
.password_hash
.as_ref()
.map(|s| s.as_bytes())
.unwrap_or(&[])
}
}
#[derive(Clone)]
pub struct AuthBackend {
pub user_repo: Arc<dyn UserRepository>,
}
impl AuthBackend {
pub fn new(user_repo: Arc<dyn UserRepository>) -> Self {
Self { user_repo }
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Credentials {
pub email: String,
pub password: String,
}
impl AuthnBackend for AuthBackend {
type User = AuthUser;
type Credentials = Credentials;
type Error = ApiError;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user = self
.user_repo
.find_by_email(&creds.email)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
if let Some(user) = user {
if let Some(hash) = &user.password_hash {
// Verify password
if verify_password(&creds.password, hash).is_ok() {
return Ok(Some(AuthUser(user)));
}
}
}
Ok(None)
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user = self
.user_repo
.find_by_id(*user_id)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(user.map(AuthUser))
}
}

148
notes-api/src/dto.rs Normal file
View File

@@ -0,0 +1,148 @@
//! Request and Response DTOs for notes API
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;
use notes_domain::{Note, NoteFilter, 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"))]
pub title: String,
#[serde(default)]
pub content: String,
#[serde(default)]
#[validate(length(max = 10, message = "Maximum 10 tags allowed"))]
pub tags: Vec<String>,
pub color: Option<String>,
#[serde(default)]
pub is_pinned: bool,
}
/// 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"))]
pub title: Option<String>,
pub content: Option<String>,
#[validate(length(max = 10, message = "Maximum 10 tags allowed"))]
pub tags: Option<Vec<String>>,
pub color: Option<String>,
pub is_pinned: Option<bool>,
pub is_archived: Option<bool>,
}
/// Query parameters for listing notes
#[derive(Debug, Deserialize, Default)]
pub struct ListNotesQuery {
pub pinned: Option<bool>,
pub archived: Option<bool>,
pub tag: Option<Uuid>,
}
impl From<ListNotesQuery> for NoteFilter {
fn from(query: ListNotesQuery) -> Self {
let mut filter = NoteFilter::new();
filter.is_pinned = query.pinned;
filter.is_archived = query.archived;
filter.tag_id = query.tag;
filter
}
}
/// Query parameters for search
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: String,
}
/// Tag response DTO
#[derive(Debug, Serialize)]
pub struct TagResponse {
pub id: Uuid,
pub name: String,
}
impl From<Tag> for TagResponse {
fn from(tag: Tag) -> Self {
Self {
id: tag.id,
name: tag.name,
}
}
}
/// Note response DTO
#[derive(Debug, Serialize)]
pub struct NoteResponse {
pub id: Uuid,
pub title: String,
pub content: String,
pub color: String,
pub is_pinned: bool,
pub is_archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub tags: Vec<TagResponse>,
}
impl From<Note> for NoteResponse {
fn from(note: Note) -> Self {
Self {
id: note.id,
title: note.title,
content: note.content,
color: note.color,
is_pinned: note.is_pinned,
is_archived: note.is_archived,
created_at: note.created_at,
updated_at: note.updated_at,
tags: note.tags.into_iter().map(TagResponse::from).collect(),
}
}
}
/// Request to create a new tag
#[derive(Debug, Deserialize, Validate)]
pub struct CreateTagRequest {
#[validate(length(min = 1, max = 50, message = "Tag name must be 1-50 characters"))]
pub name: String,
}
/// Login request
#[derive(Debug, Deserialize, Validate)]
pub struct LoginRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
pub password: String,
}
/// Register request
#[derive(Debug, Deserialize, Validate)]
pub struct RegisterRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
pub password: String,
}
/// User response DTO
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub created_at: DateTime<Utc>,
}

103
notes-api/src/error.rs Normal file
View File

@@ -0,0 +1,103 @@
//! API error handling
//!
//! Maps domain errors to HTTP responses
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use thiserror::Error;
use notes_domain::DomainError;
/// API-level errors
#[derive(Debug, Error)]
pub enum ApiError {
#[error("{0}")]
Domain(#[from] DomainError),
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal server error")]
Internal(String),
}
/// Error response body
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_response) = match &self {
ApiError::Domain(domain_error) => {
let status = match domain_error {
DomainError::NoteNotFound(_)
| DomainError::UserNotFound(_)
| DomainError::TagNotFound(_) => StatusCode::NOT_FOUND,
DomainError::UserAlreadyExists(_) | DomainError::TagAlreadyExists(_) => {
StatusCode::CONFLICT
}
DomainError::TagLimitExceeded { .. } | DomainError::ValidationError(_) => {
StatusCode::BAD_REQUEST
}
DomainError::Unauthorized(_) => StatusCode::FORBIDDEN,
DomainError::RepositoryError(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
ErrorResponse {
error: domain_error.to_string(),
details: None,
},
)
}
ApiError::Validation(msg) => (
StatusCode::BAD_REQUEST,
ErrorResponse {
error: "Validation error".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Internal(msg) => {
// Log internal errors but don't expose details
tracing::error!("Internal error: {}", msg);
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponse {
error: "Internal server error".to_string(),
details: None,
},
)
}
};
(status, Json(error_response)).into_response()
}
}
impl ApiError {
pub fn validation(msg: impl Into<String>) -> Self {
Self::Validation(msg.into())
}
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
}
/// Result type alias for API handlers
pub type ApiResult<T> = Result<T, ApiError>;

183
notes-api/src/main.rs Normal file
View File

@@ -0,0 +1,183 @@
//! K-Notes API Server
//!
//! A high-performance, self-hosted note-taking API following hexagonal architecture.
use std::sync::Arc;
use time::Duration;
use axum::Router;
use axum_login::AuthManagerLayerBuilder;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::SqliteStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use notes_infra::{
DatabaseConfig, SqliteNoteRepository, SqliteTagRepository, SqliteUserRepository, create_pool,
run_migrations,
};
mod auth;
mod dto;
mod error;
mod routes;
mod state;
use auth::AuthBackend;
use state::AppState;
/// Server configuration
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub database_url: String,
pub session_secret: String,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 3000,
database_url: "sqlite:data.db?mode=rwc".to_string(),
session_secret: "k-notes-super-secret-key-must-be-at-least-64-bytes-long!!!!"
.to_string(),
}
}
}
impl ServerConfig {
pub fn from_env() -> Self {
Self {
host: std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()),
port: std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000),
database_url: std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string()),
session_secret: std::env::var("SESSION_SECRET").unwrap_or_else(|_| {
"k-notes-super-secret-key-must-be-at-least-64-bytes-long!!!!".to_string()
}),
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "notes_api=debug,tower_http=debug,axum_login=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// Load configuration
let config = ServerConfig::from_env();
// Setup database
tracing::info!("Connecting to database: {}", config.database_url);
let db_config = DatabaseConfig::new(&config.database_url);
let pool = create_pool(&db_config).await?;
// Run migrations
tracing::info!("Running database migrations...");
run_migrations(&pool).await?;
// Create a default user for development (optional now that we have registration)
create_dev_user(&pool).await?;
// Create repositories
let note_repo = Arc::new(SqliteNoteRepository::new(pool.clone()));
let tag_repo = Arc::new(SqliteTagRepository::new(pool.clone()));
let user_repo = Arc::new(SqliteUserRepository::new(pool.clone()));
// Create application state
let state = AppState::new(note_repo, tag_repo, user_repo.clone());
// Auth backend
let backend = AuthBackend::new(user_repo);
// Session layer
let session_store = SqliteStore::new(pool.clone());
session_store.migrate().await?;
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false) // Set to true in production with HTTPS
.with_expiry(Expiry::OnInactivity(Duration::seconds(60 * 60 * 24 * 7))); // 7 days
// Auth layer
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
// Build the application
let app = Router::new()
.nest("/api/v1", routes::api_v1_router())
.layer(
CorsLayer::new()
.allow_origin(
"http://localhost:5173"
.parse::<axum::http::HeaderValue>()
.unwrap(),
)
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PATCH,
axum::http::Method::DELETE,
axum::http::Method::OPTIONS,
])
.allow_headers([
axum::http::header::AUTHORIZATION,
axum::http::header::ACCEPT,
axum::http::header::CONTENT_TYPE,
])
.allow_credentials(true),
)
.layer(auth_layer)
.layer(TraceLayer::new_for_http())
.with_state(state);
// Start the server
let addr = format!("{}:{}", config.host, config.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("🚀 K-Notes API server running at http://{}", addr);
tracing::info!("🔒 Authentication enabled (axum-login)");
tracing::info!("📝 API endpoints available at /api/v1/...");
axum::serve(listener, app).await?;
Ok(())
}
/// Create a development user for testing
/// In production, users will be created via OIDC authentication
async fn create_dev_user(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
use notes_domain::{User, UserRepository};
use notes_infra::SqliteUserRepository;
use password_auth::generate_hash;
use uuid::Uuid;
let user_repo = SqliteUserRepository::new(pool.clone());
// Check if dev user exists
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() {
// Create dev user with fixed ID and password 'password'
let hash = generate_hash("password");
let user = User::with_id(
dev_user_id,
"dev|local",
"dev@localhost",
Some(hash),
chrono::Utc::now(),
);
user_repo.save(&user).await?;
tracing::info!("Created development user: dev@localhost / password");
}
Ok(())
}

View File

@@ -0,0 +1,109 @@
//! Authentication routes
use axum::{Json, extract::State, http::StatusCode};
use axum_login::AuthSession;
use validator::Validate;
use notes_domain::User;
use password_auth::generate_hash;
use crate::auth::{AuthBackend, AuthUser, Credentials};
use crate::dto::{LoginRequest, RegisterRequest};
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
/// Register a new user
pub async fn register(
State(state): State<AppState>,
mut auth_session: AuthSession<AuthBackend>,
Json(payload): Json<RegisterRequest>,
) -> ApiResult<StatusCode> {
payload
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
// Check if user exists
if state
.user_repo
.find_by_email(&payload.email)
.await
.map_err(ApiError::from)?
.is_some()
{
return Err(ApiError::Domain(
notes_domain::DomainError::UserAlreadyExists(payload.email.clone()),
));
}
// 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);
state.user_repo.save(&user).await.map_err(ApiError::from)?;
// Auto login after registration
let user = AuthUser(user);
auth_session
.login(&user)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(StatusCode::CREATED)
}
/// Login user
pub async fn login(
mut auth_session: AuthSession<AuthBackend>,
Json(payload): Json<LoginRequest>,
) -> ApiResult<StatusCode> {
payload
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
let user = auth_session
.authenticate(Credentials {
email: payload.email,
password: payload.password,
})
.await
.map_err(|e| ApiError::internal(e.to_string()))?
.ok_or_else(|| ApiError::validation("Invalid email or password"))?; // Generic error for security
auth_session
.login(&user)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(StatusCode::OK)
}
/// Logout user
pub async fn logout(mut auth_session: AuthSession<AuthBackend>) -> ApiResult<StatusCode> {
auth_session
.logout()
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(StatusCode::OK)
}
/// Get current user
pub async fn me(
auth_session: AuthSession<AuthBackend>,
) -> ApiResult<Json<crate::dto::UserResponse>> {
let user =
auth_session
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Not logged in".to_string(),
)))?;
Ok(Json(crate::dto::UserResponse {
id: user.0.id,
email: user.0.email.clone(),
created_at: user.0.created_at,
}))
}

View File

@@ -0,0 +1,35 @@
//! Route definitions and module structure
pub mod auth;
pub mod notes;
pub mod tags;
use axum::{
Router,
routing::{delete, get, post},
};
use crate::state::AppState;
/// Create the API v1 router
pub fn api_v1_router() -> Router<AppState> {
Router::new()
// Auth routes
.route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
.route("/auth/me", get(auth::me))
// Note routes
.route("/notes", get(notes::list_notes).post(notes::create_note))
.route(
"/notes/{id}",
get(notes::get_note)
.patch(notes::update_note)
.delete(notes::delete_note),
)
// Search route
.route("/search", get(notes::search_notes))
// Tag routes
.route("/tags", get(tags::list_tags).post(tags::create_tag))
.route("/tags/{id}", delete(tags::delete_tag))
}

View File

@@ -0,0 +1,179 @@
//! Note route handlers
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use axum_login::AuthSession;
use uuid::Uuid;
use validator::Validate;
use axum_login::AuthUser;
use notes_domain::{
CreateNoteRequest as DomainCreateNote, NoteService, UpdateNoteRequest as DomainUpdateNote,
};
use crate::auth::AuthBackend;
use crate::dto::{CreateNoteRequest, ListNotesQuery, NoteResponse, SearchQuery, UpdateNoteRequest};
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
/// List notes with optional filtering
/// GET /api/v1/notes
pub async fn list_notes(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Query(query): Query<ListNotesQuery>,
) -> ApiResult<Json<Vec<NoteResponse>>> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
let service = NoteService::new(state.note_repo, state.tag_repo);
let notes = service.list_notes(user_id, query.into()).await?;
let response: Vec<NoteResponse> = notes.into_iter().map(NoteResponse::from).collect();
Ok(Json(response))
}
/// Create a new note
/// POST /api/v1/notes
pub async fn create_note(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Json(payload): Json<CreateNoteRequest>,
) -> ApiResult<(StatusCode, Json<NoteResponse>)> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
// Validate input
payload
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
let service = NoteService::new(state.note_repo, state.tag_repo);
let domain_req = DomainCreateNote {
user_id,
title: payload.title,
content: payload.content,
tags: payload.tags,
color: payload.color,
is_pinned: payload.is_pinned,
};
let note = service.create_note(domain_req).await?;
Ok((StatusCode::CREATED, Json(NoteResponse::from(note))))
}
/// Get a single note by ID
/// GET /api/v1/notes/:id
pub async fn get_note(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<NoteResponse>> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
let service = NoteService::new(state.note_repo, state.tag_repo);
let note = service.get_note(id, user_id).await?;
Ok(Json(NoteResponse::from(note)))
}
/// Update a note
/// PATCH /api/v1/notes/:id
pub async fn update_note(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateNoteRequest>,
) -> ApiResult<Json<NoteResponse>> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
// Validate input
payload
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
let service = NoteService::new(state.note_repo, state.tag_repo);
let domain_req = DomainUpdateNote {
id,
user_id,
title: payload.title,
content: payload.content,
is_pinned: payload.is_pinned,
is_archived: payload.is_archived,
color: payload.color,
tags: payload.tags,
};
let note = service.update_note(domain_req).await?;
Ok(Json(NoteResponse::from(note)))
}
/// Delete a note
/// DELETE /api/v1/notes/:id
pub async fn delete_note(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
let service = NoteService::new(state.note_repo, state.tag_repo);
service.delete_note(id, user_id).await?;
Ok(StatusCode::NO_CONTENT)
}
/// Search notes
/// GET /api/v1/search
pub async fn search_notes(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Query(query): Query<SearchQuery>,
) -> ApiResult<Json<Vec<NoteResponse>>> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
let service = NoteService::new(state.note_repo, state.tag_repo);
let notes = service.search_notes(user_id, &query.q).await?;
let response: Vec<NoteResponse> = notes.into_iter().map(NoteResponse::from).collect();
Ok(Json(response))
}

View File

@@ -0,0 +1,70 @@
//! Tag route handlers
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use axum_login::{AuthSession, AuthUser};
use uuid::Uuid;
use validator::Validate;
use notes_domain::TagService;
use crate::auth::AuthBackend;
use crate::dto::{CreateTagRequest, TagResponse};
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
/// List all tags for the user
/// GET /api/v1/tags
pub async fn list_tags(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
) -> ApiResult<Json<Vec<TagResponse>>> {
let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?;
let user_id = user.id();
let service = TagService::new(state.tag_repo);
let tags = service.list_tags(user_id).await?;
let response: Vec<TagResponse> = tags.into_iter().map(TagResponse::from).collect();
Ok(Json(response))
}
/// Create a new tag
/// POST /api/v1/tags
pub async fn create_tag(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Json(payload): Json<CreateTagRequest>,
) -> ApiResult<(StatusCode, Json<TagResponse>)> {
let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?;
let user_id = user.id();
payload.validate().map_err(|e| ApiError::validation(e.to_string()))?;
let service = TagService::new(state.tag_repo);
let tag = service.create_tag(user_id, &payload.name).await?;
Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
}
/// Delete a tag
/// DELETE /api/v1/tags/:id
pub async fn delete_tag(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?;
let user_id = user.id();
let service = TagService::new(state.tag_repo);
service.delete_tag(id, user_id).await?;
Ok(StatusCode::NO_CONTENT)
}

27
notes-api/src/state.rs Normal file
View File

@@ -0,0 +1,27 @@
//! Application state for dependency injection
use std::sync::Arc;
use notes_domain::{NoteRepository, TagRepository, UserRepository};
/// Application state holding all dependencies
#[derive(Clone)]
pub struct AppState {
pub note_repo: Arc<dyn NoteRepository>,
pub tag_repo: Arc<dyn TagRepository>,
pub user_repo: Arc<dyn UserRepository>,
}
impl AppState {
pub fn new(
note_repo: Arc<dyn NoteRepository>,
tag_repo: Arc<dyn TagRepository>,
user_repo: Arc<dyn UserRepository>,
) -> Self {
Self {
note_repo,
tag_repo,
user_repo,
}
}
}