1use crate::model::RetrievalMode;
27use crate::pipeline::{ConvertOptions, IngestOutcome, Pipeline};
28use crate::source::SourceRef;
29use crate::{RagError, Result};
30use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
31use axum::http::{header, StatusCode};
32use axum::middleware::{self, Next};
33use axum::response::{Html, IntoResponse, Response};
34use axum::routing::get;
35use axum::{Json, Router};
36use serde::Deserialize;
37use serde_json::json;
38use std::collections::HashSet;
39use std::str::FromStr;
40use std::sync::Arc;
41
42struct AppState {
43 pipeline: Pipeline,
44 keys: HashSet<String>,
45}
46
47pub fn router(pipeline: Pipeline, keys: Vec<String>) -> Result<Router> {
49 if keys.is_empty() {
50 return Err(RagError::config(
51 "RAG_API_KEYS must contain at least one key to start the REST API",
52 ));
53 }
54 let state = Arc::new(AppState {
55 pipeline,
56 keys: keys.into_iter().collect(),
57 });
58
59 let protected = Router::new()
60 .route("/api/stats", get(stats))
61 .route("/api/documents", get(list_documents).post(upload_document))
62 .route(
63 "/api/documents/{id}",
64 get(get_document).delete(delete_document),
65 )
66 .route("/api/documents/{id}/markdown", get(document_markdown))
67 .route("/api/search", get(search_get).post(search_post))
68 .layer(DefaultBodyLimit::max(256 * 1024 * 1024))
71 .layer(middleware::from_fn_with_state(state.clone(), auth));
72
73 Ok(Router::new()
74 .route("/", get(|| async { Html(include_str!("ui.html")) }))
78 .route("/health", get(health))
79 .merge(protected)
80 .with_state(state))
81}
82
83async fn health(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
88 let llm = state.pipeline.has_llm();
89 Json(json!({
90 "status": "ok",
91 "llm": llm,
92 "llm_model": llm.then(|| state.pipeline.config().llm_model.clone()),
93 }))
94}
95
96pub async fn serve(pipeline: Pipeline, addr: &str, keys: Vec<String>) -> Result<()> {
98 let app = router(pipeline, keys)?;
99 let listener = tokio::net::TcpListener::bind(addr)
100 .await
101 .map_err(|e| RagError::config(format!("cannot bind {addr}: {e}")))?;
102 tracing::info!(%addr, "REST API listening");
103 axum::serve(listener, app)
104 .await
105 .map_err(|e| RagError::config(format!("server error: {e}")))
106}
107
108async fn auth(State(state): State<Arc<AppState>>, req: Request, next: Next) -> Response {
109 let headers = req.headers();
110 let provided = headers
111 .get("x-api-key")
112 .and_then(|v| v.to_str().ok())
113 .map(str::to_string)
114 .or_else(|| {
115 headers
116 .get(header::AUTHORIZATION)
117 .and_then(|v| v.to_str().ok())
118 .and_then(|v| v.strip_prefix("Bearer "))
119 .map(str::to_string)
120 })
121 .or_else(|| query_param(req.uri().query(), "api_key"));
124 match provided {
125 Some(key) if state.keys.contains(&key) => next.run(req).await,
126 _ => err(StatusCode::UNAUTHORIZED, "invalid or missing API key").into_response(),
127 }
128}
129
130fn query_param(query: Option<&str>, name: &str) -> Option<String> {
132 query?.split('&').find_map(|pair| {
133 let (k, v) = pair.split_once('=')?;
134 (k == name).then(|| percent_decode(v))
135 })
136}
137
138fn percent_decode(s: &str) -> String {
139 let bytes = s.as_bytes();
140 let mut out = Vec::with_capacity(bytes.len());
141 let mut i = 0;
142 while i < bytes.len() {
143 match bytes[i] {
144 b'%' if i + 2 < bytes.len() => {
145 let hex = [bytes[i + 1], bytes[i + 2]];
146 match std::str::from_utf8(&hex)
147 .ok()
148 .and_then(|h| u8::from_str_radix(h, 16).ok())
149 {
150 Some(b) => {
151 out.push(b);
152 i += 3;
153 }
154 None => {
155 out.push(b'%');
156 i += 1;
157 }
158 }
159 }
160 b'+' => {
161 out.push(b' ');
162 i += 1;
163 }
164 b => {
165 out.push(b);
166 i += 1;
167 }
168 }
169 }
170 String::from_utf8_lossy(&out).into_owned()
171}
172
173type ApiResult = std::result::Result<Response, (StatusCode, Json<serde_json::Value>)>;
174
175fn err(code: StatusCode, msg: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
176 (code, Json(json!({"error": msg.to_string()})))
177}
178
179fn internal(e: RagError) -> (StatusCode, Json<serde_json::Value>) {
180 err(StatusCode::INTERNAL_SERVER_ERROR, e)
181}
182
183async fn stats(State(state): State<Arc<AppState>>) -> ApiResult {
184 let store = state.pipeline.store();
185 let documents = store.count_documents().await.map_err(internal)?;
186 let chunks = store.count_chunks().await.map_err(internal)?;
187 Ok(Json(json!({"documents": documents, "chunks": chunks})).into_response())
188}
189
190async fn list_documents(State(state): State<Arc<AppState>>) -> ApiResult {
191 let docs = state
192 .pipeline
193 .store()
194 .list_documents()
195 .await
196 .map_err(internal)?;
197 let docs: Vec<serde_json::Value> = docs.iter().map(doc_json).collect();
198 Ok(Json(json!({"documents": docs})).into_response())
199}
200
201fn doc_json(doc: &crate::model::Document) -> serde_json::Value {
205 let mut v = serde_json::to_value(doc).unwrap_or_default();
206 if let Some(meta) = v.get_mut("metadata").and_then(|m| m.as_object_mut()) {
207 if meta.remove("markdown").is_some() {
208 meta.insert("has_markdown".into(), json!(true));
209 }
210 }
211 v
212}
213
214#[derive(Debug, Deserialize)]
219struct UploadParams {
220 name: String,
221 #[serde(default)]
222 enrich_pictures: bool,
223 #[serde(default)]
224 enrich_code: bool,
225 #[serde(default)]
226 enrich_formulas: bool,
227}
228
229async fn upload_document(
234 State(state): State<Arc<AppState>>,
235 Query(params): Query<UploadParams>,
236 body: axum::body::Bytes,
237) -> ApiResult {
238 let name = params
241 .name
242 .rsplit(['/', '\\'])
243 .next()
244 .unwrap_or_default()
245 .trim()
246 .to_string();
247 if name.is_empty() {
248 return Err(err(StatusCode::BAD_REQUEST, "name must not be empty"));
249 }
250 if body.is_empty() {
251 return Err(err(StatusCode::BAD_REQUEST, "empty body"));
252 }
253 let r = SourceRef {
254 uri: format!("upload:///{name}"),
255 name: name.clone(),
256 rel_path: name.clone(),
257 };
258 let opts = ConvertOptions {
259 enrich_pictures: params.enrich_pictures,
260 enrich_code: params.enrich_code,
261 enrich_formulas: params.enrich_formulas,
262 };
263 match state
264 .pipeline
265 .ingest_bytes_with(&r, body.to_vec(), opts)
266 .await
267 {
268 Ok(IngestOutcome::Ingested(chunks)) => {
269 let stored = state
272 .pipeline
273 .store()
274 .list_documents()
275 .await
276 .ok()
277 .and_then(|docs| docs.into_iter().find(|d| d.source_uri == r.uri));
278 let (id, metrics) = stored
279 .map(|d| (json!(d.id), d.metadata.get("metrics").cloned()))
280 .unwrap_or((serde_json::Value::Null, None));
281 Ok(Json(json!({
282 "outcome": "ingested",
283 "name": name,
284 "chunks": chunks,
285 "id": id,
286 "metrics": metrics,
287 }))
288 .into_response())
289 }
290 Ok(IngestOutcome::Skipped) => Ok(Json(json!({
291 "outcome": "skipped",
292 "name": name,
293 }))
294 .into_response()),
295 Err(e @ RagError::Conversion(_)) => Err(err(StatusCode::BAD_REQUEST, e)),
297 Err(other) => Err(internal(other)),
298 }
299}
300
301async fn delete_document(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> ApiResult {
303 let docs = state
304 .pipeline
305 .store()
306 .list_documents()
307 .await
308 .map_err(internal)?;
309 if !docs.iter().any(|d| d.id == id) {
310 return Err(err(
311 StatusCode::NOT_FOUND,
312 format!("no document with id '{id}'"),
313 ));
314 }
315 state
316 .pipeline
317 .store()
318 .delete_document(&id)
319 .await
320 .map_err(internal)?;
321 state.pipeline.invalidate_keyword_index();
323 Ok(Json(json!({"deleted": id})).into_response())
324}
325
326async fn get_document(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> ApiResult {
327 let docs = state
328 .pipeline
329 .store()
330 .list_documents()
331 .await
332 .map_err(internal)?;
333 match docs.into_iter().find(|d| d.id == id) {
334 Some(doc) => {
335 let chunks = state
339 .pipeline
340 .store()
341 .count_chunks_for(&doc.id)
342 .await
343 .map_err(internal)?;
344 let processing = doc.hash.starts_with("pending:");
345 let mut body = doc_json(&doc);
346 if let Some(obj) = body.as_object_mut() {
347 obj.insert("chunks".into(), json!(chunks));
348 obj.insert("processing".into(), json!(processing));
349 }
350 Ok(Json(body).into_response())
351 }
352 None => Err(err(
353 StatusCode::NOT_FOUND,
354 format!("no document with id '{id}'"),
355 )),
356 }
357}
358
359async fn document_markdown(
364 State(state): State<Arc<AppState>>,
365 Path(id): Path<String>,
366) -> ApiResult {
367 let docs = state
368 .pipeline
369 .store()
370 .list_documents()
371 .await
372 .map_err(internal)?;
373 let doc = docs
374 .into_iter()
375 .find(|d| d.id == id)
376 .ok_or_else(|| err(StatusCode::NOT_FOUND, format!("no document with id '{id}'")))?;
377 match doc.metadata.get("markdown").and_then(|m| m.as_str()) {
378 Some(md) => Ok((
379 [(header::CONTENT_TYPE, "text/markdown; charset=utf-8")],
380 md.to_string(),
381 )
382 .into_response()),
383 None => Err(err(
384 StatusCode::NOT_FOUND,
385 "no stored markdown for this document (ingested before markdown was persisted — re-upload to backfill)",
386 )),
387 }
388}
389
390#[derive(Debug, Deserialize)]
392struct SearchParams {
393 #[serde(alias = "q")]
395 query: String,
396 mode: Option<String>,
398 #[serde(alias = "k")]
400 top_k: Option<usize>,
401 #[serde(default)]
403 answer: bool,
404 #[serde(default)]
408 extend: bool,
409}
410
411async fn results_json(
415 state: &Arc<AppState>,
416 hits: &[crate::model::Scored],
417 extend: bool,
418) -> serde_json::Value {
419 if !extend {
420 return json!(hits);
421 }
422 let mut out = Vec::with_capacity(hits.len());
423 for hit in hits {
424 let context = state
425 .pipeline
426 .store()
427 .chunk_neighborhood(&hit.chunk.doc_id, hit.chunk.ordinal)
428 .await
429 .map(|n| {
430 n.iter()
431 .map(|c| c.text.as_str())
432 .collect::<Vec<_>>()
433 .join("\n\n")
434 })
435 .unwrap_or_else(|_| hit.chunk.text.clone());
436 let mut v = json!(hit);
437 if let Some(obj) = v.as_object_mut() {
438 obj.insert("context".into(), json!(context));
439 }
440 out.push(v);
441 }
442 json!(out)
443}
444
445async fn search_get(
446 State(state): State<Arc<AppState>>,
447 Query(params): Query<SearchParams>,
448) -> ApiResult {
449 run_search(state, params).await
450}
451
452async fn search_post(
453 State(state): State<Arc<AppState>>,
454 Json(params): Json<SearchParams>,
455) -> ApiResult {
456 run_search(state, params).await
457}
458
459async fn run_search(state: Arc<AppState>, params: SearchParams) -> ApiResult {
460 if params.query.trim().is_empty() {
461 return Err(err(StatusCode::BAD_REQUEST, "query must not be empty"));
462 }
463 let mode = match ¶ms.mode {
464 Some(m) => RetrievalMode::from_str(m).map_err(|e| err(StatusCode::BAD_REQUEST, e))?,
465 None => state.pipeline.config().retrieval_mode,
466 };
467 let k = params
468 .top_k
469 .unwrap_or(state.pipeline.config().top_k)
470 .clamp(1, 100);
471
472 if params.answer {
473 let a = state
474 .pipeline
475 .answer(¶ms.query, mode, k)
476 .await
477 .map_err(|e| match e {
478 RagError::Llm(_) => err(StatusCode::BAD_REQUEST, e),
479 other => internal(other),
480 })?;
481 let results = results_json(&state, &a.sources, params.extend).await;
482 return Ok(Json(json!({
483 "query": params.query,
484 "mode": mode.to_string(),
485 "answer": a.text,
486 "results": results,
487 }))
488 .into_response());
489 }
490
491 let hits = state
492 .pipeline
493 .query(mode, ¶ms.query, k)
494 .await
495 .map_err(|e| match e {
496 RagError::Llm(_) => err(StatusCode::BAD_REQUEST, e),
497 other => internal(other),
498 })?;
499 let results = results_json(&state, &hits, params.extend).await;
500 Ok(Json(json!({
501 "query": params.query,
502 "mode": mode.to_string(),
503 "results": results,
504 }))
505 .into_response())
506}