use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::routing::{delete, get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::CurrentUser; use crate::domain::collection::{ Collection, CollectionPatch, CollectionSummary, NewCollection, }; use crate::domain::manga::Manga; use crate::domain::patch::Patch; use crate::error::{AppError, AppResult}; use crate::repo; pub fn routes() -> Router { Router::new() .route("/collections", post(create)) .route("/me/collections", get(list_mine)) .route("/collections/:id", get(get_one).patch(update).delete(delete_one)) .route("/collections/:id/mangas", get(list_mangas).post(add_manga)) .route( "/collections/:id/mangas/:manga_id", delete(remove_manga), ) .route( "/mangas/:id/my-collections", get(list_my_collections_containing), ) } const MAX_NAME_LEN: usize = 64; const MAX_DESCRIPTION_LEN: usize = 1024; const DEFAULT_LIMIT: i64 = 50; #[derive(Debug, Deserialize)] pub struct ListParams { #[serde(default = "default_limit")] pub limit: i64, #[serde(default)] pub offset: i64, } fn default_limit() -> i64 { DEFAULT_LIMIT } #[derive(Debug, Deserialize)] pub struct AddMangaBody { pub manga_id: Uuid, } #[derive(Debug, Serialize)] pub struct MangaCollectionIds { pub collection_ids: Vec, } fn validate_name(name: &str) -> AppResult<()> { let trimmed = name.trim(); if trimmed.is_empty() { return Err(AppError::ValidationFailed { message: "name is required".into(), details: json!({ "name": "required" }), }); } if trimmed.chars().count() > MAX_NAME_LEN { return Err(AppError::ValidationFailed { message: "name too long".into(), details: json!({ "name": format!("max {MAX_NAME_LEN} characters") }), }); } Ok(()) } fn validate_description(desc: Option<&str>) -> AppResult<()> { if let Some(d) = desc { if d.chars().count() > MAX_DESCRIPTION_LEN { return Err(AppError::ValidationFailed { message: "description too long".into(), details: json!({ "description": format!("max {MAX_DESCRIPTION_LEN} characters") }), }); } } Ok(()) } async fn create( State(state): State, CurrentUser(user): CurrentUser, Json(input): Json, ) -> AppResult<(StatusCode, Json)> { validate_name(&input.name)?; validate_description(input.description.as_deref())?; let row = repo::collection::create( &state.db, user.id, &input.name, input.description.as_deref(), ) .await?; Ok((StatusCode::CREATED, Json(row))) } async fn list_mine( State(state): State, CurrentUser(user): CurrentUser, Query(params): Query, ) -> AppResult>> { let limit = params.limit.clamp(1, 200); let offset = params.offset.max(0); let (items, total) = repo::collection::list_for_user(&state.db, user.id, limit, offset).await?; Ok(Json(PagedResponse::with_total(items, limit, offset, total))) } async fn get_one( State(state): State, CurrentUser(user): CurrentUser, Path(id): Path, ) -> AppResult> { let row = require_owner(&state, user.id, id).await?; Ok(Json(row)) } async fn update( State(state): State, CurrentUser(user): CurrentUser, Path(id): Path, Json(patch): Json, ) -> AppResult> { require_owner_id(&state, user.id, id).await?; if let Some(ref n) = patch.name { validate_name(n)?; } if let Patch::Set(ref d) = patch.description { validate_description(Some(d.as_str()))?; } // Three-state semantics via `Patch`: omitted → Unchanged // (column untouched), explicit `null` → Clear (NULL), value → Set. let description_provided = patch.description.is_provided(); let description_value: Option<&str> = match &patch.description { Patch::Set(s) => Some(s.as_str()), Patch::Clear | Patch::Unchanged => None, }; let updated = repo::collection::update( &state.db, id, patch.name.as_deref(), description_provided, description_value, ) .await?; Ok(Json(updated)) } async fn delete_one( State(state): State, CurrentUser(user): CurrentUser, Path(id): Path, ) -> AppResult { require_owner_id(&state, user.id, id).await?; repo::collection::delete(&state.db, id).await?; Ok(StatusCode::NO_CONTENT) } async fn list_mangas( State(state): State, CurrentUser(user): CurrentUser, Path(id): Path, Query(params): Query, ) -> AppResult>> { require_owner_id(&state, user.id, id).await?; let limit = params.limit.clamp(1, 200); let offset = params.offset.max(0); let (items, total) = repo::collection::list_mangas(&state.db, id, limit, offset).await?; Ok(Json(PagedResponse::with_total(items, limit, offset, total))) } async fn add_manga( State(state): State, CurrentUser(user): CurrentUser, Path(id): Path, Json(body): Json, ) -> AppResult { require_owner_id(&state, user.id, id).await?; if !repo::manga::exists(&state.db, body.manga_id).await? { return Err(AppError::NotFound); } let created = repo::collection::add_manga(&state.db, id, body.manga_id).await?; Ok(if created { StatusCode::CREATED } else { StatusCode::OK }) } async fn remove_manga( State(state): State, CurrentUser(user): CurrentUser, Path((collection_id, manga_id)): Path<(Uuid, Uuid)>, ) -> AppResult { require_owner_id(&state, user.id, collection_id).await?; repo::collection::remove_manga(&state.db, collection_id, manga_id).await?; Ok(StatusCode::NO_CONTENT) } async fn list_my_collections_containing( State(state): State, CurrentUser(user): CurrentUser, Path(manga_id): Path, ) -> AppResult> { // No 404 if the manga doesn't exist — the empty list is the // correct answer ("you have it in zero of your collections") and // keeps the request side-effect-free. let ids = repo::collection::list_collections_containing(&state.db, user.id, manga_id).await?; Ok(Json(MangaCollectionIds { collection_ids: ids })) } /// Returns the row iff the caller owns it. Both "doesn't exist" and /// "exists but belongs to someone else" surface as `NotFound` so the /// API doesn't disclose collection existence to non-owners — the /// frontend already does this funnelling for URLs, and consistency at /// the API matters because the same identifiers travel through bots /// and shared links. async fn require_owner( state: &AppState, user_id: Uuid, id: Uuid, ) -> AppResult { match repo::collection::get(&state.db, id).await { Ok(row) if row.user_id == user_id => Ok(row), // Either the row doesn't exist (NotFound from `get`) or it // belongs to someone else — both collapse to NotFound. Ok(_) | Err(AppError::NotFound) => Err(AppError::NotFound), Err(other) => Err(other), } } async fn require_owner_id(state: &AppState, user_id: Uuid, id: Uuid) -> AppResult<()> { match repo::collection::find_owner(&state.db, id).await? { Some(owner) if owner == user_id => Ok(()), // Same non-leakage rationale as `require_owner` above. _ => Err(AppError::NotFound), } }