init
This commit is contained in:
87
notes-api/src/auth.rs
Normal file
87
notes-api/src/auth.rs
Normal 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
148
notes-api/src/dto.rs
Normal 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
103
notes-api/src/error.rs
Normal 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
183
notes-api/src/main.rs
Normal 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(())
|
||||
}
|
||||
109
notes-api/src/routes/auth.rs
Normal file
109
notes-api/src/routes/auth.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
35
notes-api/src/routes/mod.rs
Normal file
35
notes-api/src/routes/mod.rs
Normal 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))
|
||||
}
|
||||
179
notes-api/src/routes/notes.rs
Normal file
179
notes-api/src/routes/notes.rs
Normal 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))
|
||||
}
|
||||
70
notes-api/src/routes/tags.rs
Normal file
70
notes-api/src/routes/tags.rs
Normal 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
27
notes-api/src/state.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user