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); `llm`: answers configured |
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(health))
79        .merge(protected)
80        .with_state(state))
81}
82
83/// Liveness probe, plus what the deployment can do: `llm` says whether
84/// answer synthesis is configured (`OPENROUTER_API_KEY`), so the UI can grey
85/// out "LLM answer" with the reason instead of surfacing a 400 after the
86/// fact. Holds no data, so it stays public like `/`.
87async 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
96/// Bind `addr` and serve until the process is stopped.
97pub 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        // Links the browser opens directly (e.g. the UI's "md" view) cannot
122        // set headers, so the key is also accepted as a query parameter.
123        .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
130/// One value out of a raw query string, percent-decoded (`+` is a space).
131fn 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
201/// A document as the JSON API exposes it: metadata minus the full parsed
202/// Markdown, which can be megabytes — the UI polls the document list, and
203/// the text has its own endpoint (`…/{id}/markdown`).
204fn 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/// Upload parameters: the file name (drives format detection) as `?name=`,
215/// plus optional enrichment switches (`?enrich_pictures=true&…`) mapping to
216/// docling's enrichment models — each needs its model files on disk
217/// (`download_dependencies.sh`; code/formula need `--enrich`).
218#[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
229/// `POST /api/documents?name=report.pdf` with the raw file bytes as the body:
230/// convert → chunk → embed → store, exactly the ingest pipeline. Responds
231/// with the outcome (`ingested` + chunk count, or `skipped` when an identical
232/// document is already stored).
233async fn upload_document(
234    State(state): State<Arc<AppState>>,
235    Query(params): Query<UploadParams>,
236    body: axum::body::Bytes,
237) -> ApiResult {
238    // Keep only the final path segment: the name is caller-supplied and only
239    // needed for format detection + display, never as a filesystem path.
240    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            // Include the stored row's id + per-phase processing metrics so
270            // the caller (the UI) can show where the time went.
271            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        // A document the converter rejects is the caller's input, not ours.
296        Err(e @ RagError::Conversion(_)) => Err(err(StatusCode::BAD_REQUEST, e)),
297        Err(other) => Err(internal(other)),
298    }
299}
300
301/// `DELETE /api/documents/{id}`: remove the document and all its chunks.
302async 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    // The keyword index still holds this document's chunks.
322    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            // Augment with the live chunk count and an in-progress marker
336            // (the document row exists with a `pending:` hash while its
337            // ingest is still running) — the UI polls this during uploads.
338            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
359/// `GET /api/documents/{id}/markdown`: the parsed Markdown as stored at
360/// ingest, served as `text/markdown` so a browser tab renders/downloads it
361/// directly. 404 for unknown ids and for documents ingested before the
362/// Markdown was persisted (re-upload to backfill).
363async 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/// Search parameters, shared by the GET (query-string) and POST (JSON) forms.
391#[derive(Debug, Deserialize)]
392struct SearchParams {
393    /// The search query (`q` also accepted on GET).
394    #[serde(alias = "q")]
395    query: String,
396    /// vector | bm25 | hybrid | multi-query | hyde. Defaults to the configured mode.
397    mode: Option<String>,
398    /// Number of results (default: configured top_k).
399    #[serde(alias = "k")]
400    top_k: Option<usize>,
401    /// Also synthesize an LLM answer grounded in the results.
402    #[serde(default)]
403    answer: bool,
404    /// Extend every hit with its ordinal neighbors (one chunk before, one
405    /// after, same document) — each result gains a `context` string. Purely
406    /// presentational: scoring and the LLM answer see the original chunks.
407    #[serde(default)]
408    extend: bool,
409}
410
411/// Serialize hits, optionally widening each one to `prev + hit + next` from
412/// the store (adjacent window chunks may repeat their small overlap — that's
413/// inherent to the chunker, not stitched away here).
414async 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 &params.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(&params.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, &params.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}