Skip to main content

docling_rag/
api.rs

1//! REST API over a [`Pipeline`]: document info and search in every retrieval mode.
2//!
3//! Authentication is a static API-key list from config (`RAG_API_KEYS`), accepted
4//! as `X-Api-Key: <key>`, `Authorization: Bearer <key>`, or — for links a browser
5//! opens directly, where no header can be set — `?api_key=<key>`. Auth is
6//! fail-closed: [`router`] errors when the key list is empty. `GET /health` is public.
7//!
8//! Endpoints (all under auth except `/` and `/health`):
9//!
10//! | Method | Path                  | Description                                   |
11//! |--------|-----------------------|-----------------------------------------------|
12//! | GET    | `/`                   | built-in search UI (public; static HTML)      |
13//! | GET    | `/health`             | liveness probe (public)                       |
14//! | GET    | `/api/stats`          | document / chunk counts                       |
15//! | GET    | `/api/documents`      | all documents with metadata + metrics         |
16//! | POST   | `/api/documents`      | `?name=file.pdf` + enrich flags, raw bytes body → ingest |
17//! | GET    | `/api/documents/{id}` | one document by id                            |
18//! | GET    | `/api/documents/{id}/markdown` | the parsed Markdown (`text/markdown`) |
19//! | DELETE | `/api/documents/{id}` | remove the document and all its chunks        |
20//! | GET    | `/api/search`         | `?q=…&mode=hybrid&k=5` (also accepts POST)    |
21//! | POST   | `/api/search`         | `{"query", "mode?", "top_k?", "answer?", "extend?"}` |
22//!
23//! Search modes: `vector`, `bm25`, `hybrid`, `multi-query`, `hyde`. With
24//! `answer=true` the LLM synthesizes a grounded answer (needs `OPENROUTER_API_KEY`).
25
26use 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
47/// Build the router. Errors if `keys` is empty (auth is fail-closed).
48pub 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        // Uploads are raw document bytes; axum's 2 MB default would reject
69        // any real PDF. 256 MiB comfortably covers the corpus' heaviest docs.
70        .layer(DefaultBodyLimit::max(256 * 1024 * 1024))
71        .layer(middleware::from_fn_with_state(state.clone(), auth));
72
73    Ok(Router::new()
74        // The built-in search UI: one self-contained page, no external assets.
75        // Public like /health — the page itself holds no data; every API call
76        // it makes carries the key the user stored in localStorage.
77        .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
83/// Bind `addr` and serve until the process is stopped.
84pub 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        // Links the browser opens directly (e.g. the UI's "md" view) cannot
109        // set headers, so the key is also accepted as a query parameter.
110        .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
117/// One value out of a raw query string, percent-decoded (`+` is a space).
118fn 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
188/// A document as the JSON API exposes it: metadata minus the full parsed
189/// Markdown, which can be megabytes — the UI polls the document list, and
190/// the text has its own endpoint (`…/{id}/markdown`).
191fn 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/// Upload parameters: the file name (drives format detection) as `?name=`,
202/// plus optional enrichment switches (`?enrich_pictures=true&…`) mapping to
203/// docling's enrichment models — each needs its model files on disk
204/// (`download_dependencies.sh`; code/formula need `--enrich`).
205#[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
216/// `POST /api/documents?name=report.pdf` with the raw file bytes as the body:
217/// convert → chunk → embed → store, exactly the ingest pipeline. Responds
218/// with the outcome (`ingested` + chunk count, or `skipped` when an identical
219/// document is already stored).
220async fn upload_document(
221    State(state): State<Arc<AppState>>,
222    Query(params): Query<UploadParams>,
223    body: axum::body::Bytes,
224) -> ApiResult {
225    // Keep only the final path segment: the name is caller-supplied and only
226    // needed for format detection + display, never as a filesystem path.
227    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            // Include the stored row's id + per-phase processing metrics so
257            // the caller (the UI) can show where the time went.
258            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        // A document the converter rejects is the caller's input, not ours.
283        Err(e @ RagError::Conversion(_)) => Err(err(StatusCode::BAD_REQUEST, e)),
284        Err(other) => Err(internal(other)),
285    }
286}
287
288/// `DELETE /api/documents/{id}`: remove the document and all its chunks.
289async 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            // Augment with the live chunk count and an in-progress marker
321            // (the document row exists with a `pending:` hash while its
322            // ingest is still running) — the UI polls this during uploads.
323            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
344/// `GET /api/documents/{id}/markdown`: the parsed Markdown as stored at
345/// ingest, served as `text/markdown` so a browser tab renders/downloads it
346/// directly. 404 for unknown ids and for documents ingested before the
347/// Markdown was persisted (re-upload to backfill).
348async 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/// Search parameters, shared by the GET (query-string) and POST (JSON) forms.
376#[derive(Debug, Deserialize)]
377struct SearchParams {
378    /// The search query (`q` also accepted on GET).
379    #[serde(alias = "q")]
380    query: String,
381    /// vector | bm25 | hybrid | multi-query | hyde. Defaults to the configured mode.
382    mode: Option<String>,
383    /// Number of results (default: configured top_k).
384    #[serde(alias = "k")]
385    top_k: Option<usize>,
386    /// Also synthesize an LLM answer grounded in the results.
387    #[serde(default)]
388    answer: bool,
389    /// Extend every hit with its ordinal neighbors (one chunk before, one
390    /// after, same document) — each result gains a `context` string. Purely
391    /// presentational: scoring and the LLM answer see the original chunks.
392    #[serde(default)]
393    extend: bool,
394}
395
396/// Serialize hits, optionally widening each one to `prev + hit + next` from
397/// the store (adjacent window chunks may repeat their small overlap — that's
398/// inherent to the chunker, not stitched away here).
399async 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 &params.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(&params.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, &params.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}