use std::sync::{Arc, Mutex}; use axum::{ Json, Router, extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, }; use nara_core::book::Book; use serde::{Deserialize, Serialize}; use crate::library::Library; #[derive(Clone)] pub struct AppState { library: Arc>, } pub fn router(library: Arc>) -> Router { let state = AppState { library }; Router::new() .route("/", get(root)) .route("/health", get(health)) .route("/books", get(list_books)) .route("/books/by-author", get(books_by_author)) .route("/books/by-hash/{hash}", get(book_by_hash)) .route("/search", get(search_all)) .route("/search/title", get(search_title)) .route("/search/author", get(search_author)) .route("/search/contents", get(search_contents)) .with_state(state) } #[derive(serde::Serialize)] struct RootResponse { message: &'static str, endpoints: [&'static str; 8], } async fn root() -> axum::Json { axum::Json(RootResponse { message: "Library API", endpoints: [ "/api/health", "/api/books?limit=25&offset=0", "/api/books/by-author?author=Name&limit=25&offset=0", "/api/books/by-hash/:hash", "/api/search?query=term&limit=25&offset=0", "/api/search/title?query=term&limit=25&offset=0", "/api/search/author?query=term&limit=25&offset=0", "/api/search/contents?query=term&limit=25&offset=0", ], }) } #[derive(Serialize)] struct HealthResponse { status: &'static str, books: usize, } async fn health(State(state): State) -> Json { let library = state.library.lock().expect("library mutex poisoned"); Json(HealthResponse { status: "ok", books: library.book_count(), }) } #[derive(Deserialize)] struct ListParams { limit: Option, offset: Option, } #[derive(Serialize)] struct ListResponse { total: usize, offset: usize, limit: usize, items: Vec, } async fn list_books( State(state): State, Query(params): Query, ) -> Json { let library = state.library.lock().expect("library mutex poisoned"); let total = library.book_count(); let limit = clamp_limit(params.limit); let offset = params.offset.unwrap_or(0).min(total); let items = library .all_books() .skip(offset) .take(limit) .map(|book| book_to_detail(book, &library)) .collect(); Json(ListResponse { total, offset, limit, items, }) } #[derive(Deserialize)] struct AuthorQuery { author: String, limit: Option, offset: Option, } async fn books_by_author( State(state): State, Query(query): Query, ) -> Result>, ApiError> { let library = state.library.lock().expect("library mutex poisoned"); let author = query.author.trim(); if author.is_empty() { return Err(ApiError::bad_request("author is required")); } let limit = clamp_limit(query.limit); let offset = query.offset.unwrap_or(0); let items = library .books_by_author(author) .skip(offset) .take(limit) .map(|book| book_to_detail(book, &library)) .collect(); Ok(Json(items)) } async fn book_by_hash( State(state): State, Path(hash): Path, ) -> Result, ApiError> { let library = state.library.lock().expect("library mutex poisoned"); let hash = parse_hash(&hash)?; let Some(book) = library.book_by_hash(hash) else { return Err(ApiError::not_found("book not found")); }; Ok(Json(book_to_detail(book, &library))) } #[derive(Deserialize)] struct SearchQuery { query: String, limit: Option, offset: Option, } #[derive(Serialize)] struct SearchResult { score: f64, book: BookDetail, } async fn search_all( State(state): State, Query(query): Query, ) -> Result>, ApiError> { let library = state.library.lock().expect("library mutex poisoned"); run_search(&library, query, Library::fuzzy) } async fn search_title( State(state): State, Query(query): Query, ) -> Result>, ApiError> { let library = state.library.lock().expect("library mutex poisoned"); run_search(&library, query, Library::fuzzy_title) } async fn search_author( State(state): State, Query(query): Query, ) -> Result>, ApiError> { let library = state.library.lock().expect("library mutex poisoned"); run_search(&library, query, Library::fuzzy_author) } async fn search_contents( State(state): State, Query(query): Query, ) -> Result>, ApiError> { let library = state.library.lock().expect("library mutex poisoned"); run_search(&library, query, Library::fuzzy_contents) } fn run_search( library: &Library, query: SearchQuery, search_fn: for<'a> fn(&'a Library, &str, usize) -> Vec<(&'a Book, f64)>, ) -> Result>, ApiError> { let query_str = query.query.trim(); if query_str.is_empty() { return Err(ApiError::bad_request("query is required")); } let limit = clamp_limit(query.limit); let offset = query.offset.unwrap_or(0); let fetch = clamp_limit(Some(limit.saturating_add(offset))); let items = search_fn(library, query_str, fetch) .into_iter() .skip(offset) .take(limit) .map(|(book, score)| SearchResult { score, book: book_to_detail(book, library), }) .collect(); Ok(Json(items)) } #[derive(Serialize)] struct BookDetail { book: Book, hash: String, } fn book_to_detail(book: &Book, _library: &Library) -> BookDetail { let hash = book.hash(); let hash_hex = hex::encode(hash); BookDetail { book: book.clone(), hash: hash_hex, } } fn clamp_limit(limit: Option) -> usize { let limit = limit.unwrap_or(25); limit.clamp(1, 50) } fn parse_hash(hex_str: &str) -> Result<[u8; 20], ApiError> { let raw = hex::decode(hex_str) .map_err(|_| ApiError::bad_request("invalid hash"))?; if raw.len() != 20 { return Err(ApiError::bad_request("invalid hash length")); } let mut out = [0u8; 20]; out.copy_from_slice(&raw); Ok(out) } #[derive(Debug, Serialize)] struct ErrorResponse { error: String, } #[derive(Debug)] struct ApiError { status: StatusCode, message: String, } impl ApiError { fn bad_request(message: &str) -> Self { Self { status: StatusCode::BAD_REQUEST, message: message.to_string(), } } fn not_found(message: &str) -> Self { Self { status: StatusCode::NOT_FOUND, message: message.to_string(), } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { let body = Json(ErrorResponse { error: self.message, }); (self.status, body).into_response() } }