online Minecraft written book viewer
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}