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(|| async { Json(json!({"status": "ok"})) }))
79 .merge(protected)
80 .with_state(state))
81}
82
83pub async fn serve(pipeline: Pipeline, addr: &str, keys: Vec<String>) -> Result<()> {
85 let app = router(pipeline, keys)?;
86 let listener = tokio::net::TcpListener::bind(addr)
87 .await
88 .map_err(|e| RagError::config(format!("cannot bind {addr}: {e}")))?;
89 tracing::info!(%addr, "REST API listening");
90 axum::serve(listener, app)
91 .await
92 .map_err(|e| RagError::config(format!("server error: {e}")))
93}
94
95async fn auth(State(state): State<Arc<AppState>>, req: Request, next: Next) -> Response {
96 let headers = req.headers();
97 let provided = headers
98 .get("x-api-key")
99 .and_then(|v| v.to_str().ok())
100 .map(str::to_string)
101 .or_else(|| {
102 headers
103 .get(header::AUTHORIZATION)
104 .and_then(|v| v.to_str().ok())
105 .and_then(|v| v.strip_prefix("Bearer "))
106 .map(str::to_string)
107 })
108 .or_else(|| query_param(req.uri().query(), "api_key"));
111 match provided {
112 Some(key) if state.keys.contains(&key) => next.run(req).await,
113 _ => err(StatusCode::UNAUTHORIZED, "invalid or missing API key").into_response(),
114 }
115}
116
117fn query_param(query: Option<&str>, name: &str) -> Option<String> {
119 query?.split('&').find_map(|pair| {
120 let (k, v) = pair.split_once('=')?;
121 (k == name).then(|| percent_decode(v))
122 })
123}
124
125fn percent_decode(s: &str) -> String {
126 let bytes = s.as_bytes();
127 let mut out = Vec::with_capacity(bytes.len());
128 let mut i = 0;
129 while i < bytes.len() {
130 match bytes[i] {
131 b'%' if i + 2 < bytes.len() => {
132 let hex = [bytes[i + 1], bytes[i + 2]];
133 match std::str::from_utf8(&hex)
134 .ok()
135 .and_then(|h| u8::from_str_radix(h, 16).ok())
136 {
137 Some(b) => {
138 out.push(b);
139 i += 3;
140 }
141 None => {
142 out.push(b'%');
143 i += 1;
144 }
145 }
146 }
147 b'+' => {
148 out.push(b' ');
149 i += 1;
150 }
151 b => {
152 out.push(b);
153 i += 1;
154 }
155 }
156 }
157 String::from_utf8_lossy(&out).into_owned()
158}
159
160type ApiResult = std::result::Result<Response, (StatusCode, Json<serde_json::Value>)>;
161
162fn err(code: StatusCode, msg: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
163 (code, Json(json!({"error": msg.to_string()})))
164}
165
166fn internal(e: RagError) -> (StatusCode, Json<serde_json::Value>) {
167 err(StatusCode::INTERNAL_SERVER_ERROR, e)
168}
169
170async fn stats(State(state): State<Arc<AppState>>) -> ApiResult {
171 let store = state.pipeline.store();
172 let documents = store.count_documents().await.map_err(internal)?;
173 let chunks = store.count_chunks().await.map_err(internal)?;
174 Ok(Json(json!({"documents": documents, "chunks": chunks})).into_response())
175}
176
177async fn list_documents(State(state): State<Arc<AppState>>) -> ApiResult {
178 let docs = state
179 .pipeline
180 .store()
181 .list_documents()
182 .await
183 .map_err(internal)?;
184 let docs: Vec<serde_json::Value> = docs.iter().map(doc_json).collect();
185 Ok(Json(json!({"documents": docs})).into_response())
186}
187
188fn doc_json(doc: &crate::model::Document) -> serde_json::Value {
192 let mut v = serde_json::to_value(doc).unwrap_or_default();
193 if let Some(meta) = v.get_mut("metadata").and_then(|m| m.as_object_mut()) {
194 if meta.remove("markdown").is_some() {
195 meta.insert("has_markdown".into(), json!(true));
196 }
197 }
198 v
199}
200
201#[derive(Debug, Deserialize)]
206struct UploadParams {
207 name: String,
208 #[serde(default)]
209 enrich_pictures: bool,
210 #[serde(default)]
211 enrich_code: bool,
212 #[serde(default)]
213 enrich_formulas: bool,
214}
215
216async fn upload_document(
221 State(state): State<Arc<AppState>>,
222 Query(params): Query<UploadParams>,
223 body: axum::body::Bytes,
224) -> ApiResult {
225 let name = params
228 .name
229 .rsplit(['/', '\\'])
230 .next()
231 .unwrap_or_default()
232 .trim()
233 .to_string();
234 if name.is_empty() {
235 return Err(err(StatusCode::BAD_REQUEST, "name must not be empty"));
236 }
237 if body.is_empty() {
238 return Err(err(StatusCode::BAD_REQUEST, "empty body"));
239 }
240 let r = SourceRef {
241 uri: format!("upload:///{name}"),
242 name: name.clone(),
243 rel_path: name.clone(),
244 };
245 let opts = ConvertOptions {
246 enrich_pictures: params.enrich_pictures,
247 enrich_code: params.enrich_code,
248 enrich_formulas: params.enrich_formulas,
249 };
250 match state
251 .pipeline
252 .ingest_bytes_with(&r, body.to_vec(), opts)
253 .await
254 {
255 Ok(IngestOutcome::Ingested(chunks)) => {
256 let stored = state
259 .pipeline
260 .store()
261 .list_documents()
262 .await
263 .ok()
264 .and_then(|docs| docs.into_iter().find(|d| d.source_uri == r.uri));
265 let (id, metrics) = stored
266 .map(|d| (json!(d.id), d.metadata.get("metrics").cloned()))
267 .unwrap_or((serde_json::Value::Null, None));
268 Ok(Json(json!({
269 "outcome": "ingested",
270 "name": name,
271 "chunks": chunks,
272 "id": id,
273 "metrics": metrics,
274 }))
275 .into_response())
276 }
277 Ok(IngestOutcome::Skipped) => Ok(Json(json!({
278 "outcome": "skipped",
279 "name": name,
280 }))
281 .into_response()),
282 Err(e @ RagError::Conversion(_)) => Err(err(StatusCode::BAD_REQUEST, e)),
284 Err(other) => Err(internal(other)),
285 }
286}
287
288async fn delete_document(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> ApiResult {
290 let docs = state
291 .pipeline
292 .store()
293 .list_documents()
294 .await
295 .map_err(internal)?;
296 if !docs.iter().any(|d| d.id == id) {
297 return Err(err(
298 StatusCode::NOT_FOUND,
299 format!("no document with id '{id}'"),
300 ));
301 }
302 state
303 .pipeline
304 .store()
305 .delete_document(&id)
306 .await
307 .map_err(internal)?;
308 Ok(Json(json!({"deleted": id})).into_response())
309}
310
311async fn get_document(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> ApiResult {
312 let docs = state
313 .pipeline
314 .store()
315 .list_documents()
316 .await
317 .map_err(internal)?;
318 match docs.into_iter().find(|d| d.id == id) {
319 Some(doc) => {
320 let chunks = state
324 .pipeline
325 .store()
326 .count_chunks_for(&doc.id)
327 .await
328 .map_err(internal)?;
329 let processing = doc.hash.starts_with("pending:");
330 let mut body = doc_json(&doc);
331 if let Some(obj) = body.as_object_mut() {
332 obj.insert("chunks".into(), json!(chunks));
333 obj.insert("processing".into(), json!(processing));
334 }
335 Ok(Json(body).into_response())
336 }
337 None => Err(err(
338 StatusCode::NOT_FOUND,
339 format!("no document with id '{id}'"),
340 )),
341 }
342}
343
344async fn document_markdown(
349 State(state): State<Arc<AppState>>,
350 Path(id): Path<String>,
351) -> ApiResult {
352 let docs = state
353 .pipeline
354 .store()
355 .list_documents()
356 .await
357 .map_err(internal)?;
358 let doc = docs
359 .into_iter()
360 .find(|d| d.id == id)
361 .ok_or_else(|| err(StatusCode::NOT_FOUND, format!("no document with id '{id}'")))?;
362 match doc.metadata.get("markdown").and_then(|m| m.as_str()) {
363 Some(md) => Ok((
364 [(header::CONTENT_TYPE, "text/markdown; charset=utf-8")],
365 md.to_string(),
366 )
367 .into_response()),
368 None => Err(err(
369 StatusCode::NOT_FOUND,
370 "no stored markdown for this document (ingested before markdown was persisted — re-upload to backfill)",
371 )),
372 }
373}
374
375#[derive(Debug, Deserialize)]
377struct SearchParams {
378 #[serde(alias = "q")]
380 query: String,
381 mode: Option<String>,
383 #[serde(alias = "k")]
385 top_k: Option<usize>,
386 #[serde(default)]
388 answer: bool,
389 #[serde(default)]
393 extend: bool,
394}
395
396async fn results_json(
400 state: &Arc<AppState>,
401 hits: &[crate::model::Scored],
402 extend: bool,
403) -> serde_json::Value {
404 if !extend {
405 return json!(hits);
406 }
407 let mut out = Vec::with_capacity(hits.len());
408 for hit in hits {
409 let context = state
410 .pipeline
411 .store()
412 .chunk_neighborhood(&hit.chunk.doc_id, hit.chunk.ordinal)
413 .await
414 .map(|n| {
415 n.iter()
416 .map(|c| c.text.as_str())
417 .collect::<Vec<_>>()
418 .join("\n\n")
419 })
420 .unwrap_or_else(|_| hit.chunk.text.clone());
421 let mut v = json!(hit);
422 if let Some(obj) = v.as_object_mut() {
423 obj.insert("context".into(), json!(context));
424 }
425 out.push(v);
426 }
427 json!(out)
428}
429
430async fn search_get(
431 State(state): State<Arc<AppState>>,
432 Query(params): Query<SearchParams>,
433) -> ApiResult {
434 run_search(state, params).await
435}
436
437async fn search_post(
438 State(state): State<Arc<AppState>>,
439 Json(params): Json<SearchParams>,
440) -> ApiResult {
441 run_search(state, params).await
442}
443
444async fn run_search(state: Arc<AppState>, params: SearchParams) -> ApiResult {
445 if params.query.trim().is_empty() {
446 return Err(err(StatusCode::BAD_REQUEST, "query must not be empty"));
447 }
448 let mode = match ¶ms.mode {
449 Some(m) => RetrievalMode::from_str(m).map_err(|e| err(StatusCode::BAD_REQUEST, e))?,
450 None => state.pipeline.config().retrieval_mode,
451 };
452 let k = params
453 .top_k
454 .unwrap_or(state.pipeline.config().top_k)
455 .clamp(1, 100);
456
457 if params.answer {
458 let a = state
459 .pipeline
460 .answer(¶ms.query, mode, k)
461 .await
462 .map_err(|e| match e {
463 RagError::Llm(_) => err(StatusCode::BAD_REQUEST, e),
464 other => internal(other),
465 })?;
466 let results = results_json(&state, &a.sources, params.extend).await;
467 return Ok(Json(json!({
468 "query": params.query,
469 "mode": mode.to_string(),
470 "answer": a.text,
471 "results": results,
472 }))
473 .into_response());
474 }
475
476 let hits = state
477 .pipeline
478 .query(mode, ¶ms.query, k)
479 .await
480 .map_err(|e| match e {
481 RagError::Llm(_) => err(StatusCode::BAD_REQUEST, e),
482 other => internal(other),
483 })?;
484 let results = results_json(&state, &hits, params.extend).await;
485 Ok(Json(json!({
486 "query": params.query,
487 "mode": mode.to_string(),
488 "results": results,
489 }))
490 .into_response())
491}