online Minecraft written book viewer
at main 289 lines 7.4 kB view raw
1use std::sync::{Arc, Mutex}; 2 3use axum::{ 4 Json, Router, 5 extract::{Path, Query, State}, 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 routing::get, 9}; 10use nara_core::book::Book; 11use serde::{Deserialize, Serialize}; 12 13use crate::library::Library; 14 15#[derive(Clone)] 16pub struct AppState { 17 library: Arc<Mutex<Library>>, 18} 19 20pub fn router(library: Arc<Mutex<Library>>) -> Router { 21 let state = AppState { library }; 22 Router::new() 23 .route("/", get(root)) 24 .route("/health", get(health)) 25 .route("/books", get(list_books)) 26 .route("/books/by-author", get(books_by_author)) 27 .route("/books/by-hash/{hash}", get(book_by_hash)) 28 .route("/search", get(search_all)) 29 .route("/search/title", get(search_title)) 30 .route("/search/author", get(search_author)) 31 .route("/search/contents", get(search_contents)) 32 .with_state(state) 33} 34 35#[derive(serde::Serialize)] 36struct RootResponse { 37 message: &'static str, 38 endpoints: [&'static str; 8], 39} 40 41async fn root() -> axum::Json<RootResponse> { 42 axum::Json(RootResponse { 43 message: "Library API", 44 endpoints: [ 45 "/api/health", 46 "/api/books?limit=25&offset=0", 47 "/api/books/by-author?author=Name&limit=25&offset=0", 48 "/api/books/by-hash/:hash", 49 "/api/search?query=term&limit=25&offset=0", 50 "/api/search/title?query=term&limit=25&offset=0", 51 "/api/search/author?query=term&limit=25&offset=0", 52 "/api/search/contents?query=term&limit=25&offset=0", 53 ], 54 }) 55} 56 57#[derive(Serialize)] 58struct HealthResponse { 59 status: &'static str, 60 books: usize, 61} 62 63async fn health(State(state): State<AppState>) -> Json<HealthResponse> { 64 let library = state.library.lock().expect("library mutex poisoned"); 65 Json(HealthResponse { 66 status: "ok", 67 books: library.book_count(), 68 }) 69} 70 71#[derive(Deserialize)] 72struct ListParams { 73 limit: Option<usize>, 74 offset: Option<usize>, 75} 76 77#[derive(Serialize)] 78struct ListResponse { 79 total: usize, 80 offset: usize, 81 limit: usize, 82 items: Vec<BookDetail>, 83} 84 85async fn list_books( 86 State(state): State<AppState>, 87 Query(params): Query<ListParams>, 88) -> Json<ListResponse> { 89 let library = state.library.lock().expect("library mutex poisoned"); 90 let total = library.book_count(); 91 let limit = clamp_limit(params.limit); 92 let offset = params.offset.unwrap_or(0).min(total); 93 94 let items = library 95 .all_books() 96 .skip(offset) 97 .take(limit) 98 .map(|book| book_to_detail(book, &library)) 99 .collect(); 100 101 Json(ListResponse { 102 total, 103 offset, 104 limit, 105 items, 106 }) 107} 108 109#[derive(Deserialize)] 110struct AuthorQuery { 111 author: String, 112 limit: Option<usize>, 113 offset: Option<usize>, 114} 115 116async fn books_by_author( 117 State(state): State<AppState>, 118 Query(query): Query<AuthorQuery>, 119) -> Result<Json<Vec<BookDetail>>, ApiError> { 120 let library = state.library.lock().expect("library mutex poisoned"); 121 let author = query.author.trim(); 122 if author.is_empty() { 123 return Err(ApiError::bad_request("author is required")); 124 } 125 126 let limit = clamp_limit(query.limit); 127 let offset = query.offset.unwrap_or(0); 128 129 let items = library 130 .books_by_author(author) 131 .skip(offset) 132 .take(limit) 133 .map(|book| book_to_detail(book, &library)) 134 .collect(); 135 136 Ok(Json(items)) 137} 138 139async fn book_by_hash( 140 State(state): State<AppState>, 141 Path(hash): Path<String>, 142) -> Result<Json<BookDetail>, ApiError> { 143 let library = state.library.lock().expect("library mutex poisoned"); 144 let hash = parse_hash(&hash)?; 145 let Some(book) = library.book_by_hash(hash) else { 146 return Err(ApiError::not_found("book not found")); 147 }; 148 149 Ok(Json(book_to_detail(book, &library))) 150} 151 152#[derive(Deserialize)] 153struct SearchQuery { 154 query: String, 155 limit: Option<usize>, 156 offset: Option<usize>, 157} 158 159#[derive(Serialize)] 160struct SearchResult { 161 score: f64, 162 book: BookDetail, 163} 164 165async fn search_all( 166 State(state): State<AppState>, 167 Query(query): Query<SearchQuery>, 168) -> Result<Json<Vec<SearchResult>>, ApiError> { 169 let library = state.library.lock().expect("library mutex poisoned"); 170 run_search(&library, query, Library::fuzzy) 171} 172 173async fn search_title( 174 State(state): State<AppState>, 175 Query(query): Query<SearchQuery>, 176) -> Result<Json<Vec<SearchResult>>, ApiError> { 177 let library = state.library.lock().expect("library mutex poisoned"); 178 run_search(&library, query, Library::fuzzy_title) 179} 180 181async fn search_author( 182 State(state): State<AppState>, 183 Query(query): Query<SearchQuery>, 184) -> Result<Json<Vec<SearchResult>>, ApiError> { 185 let library = state.library.lock().expect("library mutex poisoned"); 186 run_search(&library, query, Library::fuzzy_author) 187} 188 189async fn search_contents( 190 State(state): State<AppState>, 191 Query(query): Query<SearchQuery>, 192) -> Result<Json<Vec<SearchResult>>, ApiError> { 193 let library = state.library.lock().expect("library mutex poisoned"); 194 run_search(&library, query, Library::fuzzy_contents) 195} 196 197fn run_search( 198 library: &Library, 199 query: SearchQuery, 200 search_fn: for<'a> fn(&'a Library, &str, usize) -> Vec<(&'a Book, f64)>, 201) -> Result<Json<Vec<SearchResult>>, ApiError> { 202 let query_str = query.query.trim(); 203 if query_str.is_empty() { 204 return Err(ApiError::bad_request("query is required")); 205 } 206 let limit = clamp_limit(query.limit); 207 let offset = query.offset.unwrap_or(0); 208 let fetch = clamp_limit(Some(limit.saturating_add(offset))); 209 210 let items = search_fn(library, query_str, fetch) 211 .into_iter() 212 .skip(offset) 213 .take(limit) 214 .map(|(book, score)| SearchResult { 215 score, 216 book: book_to_detail(book, library), 217 }) 218 .collect(); 219 220 Ok(Json(items)) 221} 222 223#[derive(Serialize)] 224struct BookDetail { 225 book: Book, 226 hash: String, 227} 228 229fn book_to_detail(book: &Book, _library: &Library) -> BookDetail { 230 let hash = book.hash(); 231 let hash_hex = hex::encode(hash); 232 233 BookDetail { 234 book: book.clone(), 235 hash: hash_hex, 236 } 237} 238 239fn clamp_limit(limit: Option<usize>) -> usize { 240 let limit = limit.unwrap_or(25); 241 limit.clamp(1, 50) 242} 243 244fn parse_hash(hex_str: &str) -> Result<[u8; 20], ApiError> { 245 let raw = hex::decode(hex_str) 246 .map_err(|_| ApiError::bad_request("invalid hash"))?; 247 if raw.len() != 20 { 248 return Err(ApiError::bad_request("invalid hash length")); 249 } 250 let mut out = [0u8; 20]; 251 out.copy_from_slice(&raw); 252 Ok(out) 253} 254 255#[derive(Debug, Serialize)] 256struct ErrorResponse { 257 error: String, 258} 259 260#[derive(Debug)] 261struct ApiError { 262 status: StatusCode, 263 message: String, 264} 265 266impl ApiError { 267 fn bad_request(message: &str) -> Self { 268 Self { 269 status: StatusCode::BAD_REQUEST, 270 message: message.to_string(), 271 } 272 } 273 274 fn not_found(message: &str) -> Self { 275 Self { 276 status: StatusCode::NOT_FOUND, 277 message: message.to_string(), 278 } 279 } 280} 281 282impl IntoResponse for ApiError { 283 fn into_response(self) -> Response { 284 let body = Json(ErrorResponse { 285 error: self.message, 286 }); 287 (self.status, body).into_response() 288 } 289}