Skip to main content

cortex/frontend/
apidoc.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! API documentation: a generated **OpenAPI 3** spec plus a RapiDoc browser page, both built by
9//! `rocket_okapi` directly from the `#[openapi]`-annotated agent routes — the spec is generated
10//! from the single source of truth (the real Rocket route + its return type) and so can never drift
11//! from the served API. This is the symmetry contract extended to the docs (see
12//! `docs/archive/api-spike/COMPARISON.md` + OPEN_QUESTIONS #7; rocket_okapi was chosen over
13//! utoipa).
14//!
15//! Adding an endpoint to the docs is two steps: put `#[openapi(tag = "…")]` above its
16//! `#[get/post/…]` attribute, and list its handler in the [`openapi_get_routes_spec!`] call in
17//! [`mount`]. The DTOs it returns must derive `schemars::JsonSchema`.
18
19use rocket::http::ContentType;
20use rocket::{Build, Rocket, State};
21use rocket_okapi::openapi_get_routes_spec;
22use rocket_okapi::rapidoc::{
23  GeneralConfig, NavConfig, NavItemSpacing, RapiDocConfig, SlotsConfig, make_rapidoc,
24};
25use rocket_okapi::settings::{OpenApiSettings, UrlObject};
26
27// Each documented handler is imported alongside its `#[openapi]`-generated
28// `okapi_add_operation_for_*` companion (emitted in the handler's own module) — both must be in
29// scope for the `openapi_get_routes_spec!` call below. (Explicit rather than glob, so each module's
30// same-named `routes` fn doesn't collide.)
31use crate::frontend::admin::{
32  api_logs, api_status, okapi_add_operation_for_api_logs_, okapi_add_operation_for_api_status_,
33};
34use crate::frontend::audit::{api_audit, okapi_add_operation_for_api_audit_};
35use crate::frontend::corpora::{
36  activate_service, api_corpora, api_corpus, create_sandbox_corpus, deactivate_service,
37  delete_corpus, export_dataset, extend_corpus, import_corpus,
38  okapi_add_operation_for_activate_service_, okapi_add_operation_for_api_corpora_,
39  okapi_add_operation_for_api_corpus_, okapi_add_operation_for_create_sandbox_corpus_,
40  okapi_add_operation_for_deactivate_service_, okapi_add_operation_for_delete_corpus_,
41  okapi_add_operation_for_export_dataset_, okapi_add_operation_for_extend_corpus_,
42  okapi_add_operation_for_import_corpus_, okapi_add_operation_for_snapshot_tasks_, snapshot_tasks,
43};
44use crate::frontend::jobs::{
45  api_job, api_jobs, okapi_add_operation_for_api_job_, okapi_add_operation_for_api_jobs_,
46};
47use crate::frontend::management::{
48  analyze, api_config, api_health, api_index, healthz, okapi_add_operation_for_analyze_,
49  okapi_add_operation_for_api_config_, okapi_add_operation_for_api_health_,
50  okapi_add_operation_for_api_index_, okapi_add_operation_for_healthz_,
51  okapi_add_operation_for_put_config_, okapi_add_operation_for_reindex_, put_config, reindex,
52};
53use crate::frontend::reports::{
54  api_category_report, api_document, api_entry_list, api_service_overview, api_what_report,
55  okapi_add_operation_for_api_category_report_, okapi_add_operation_for_api_document_,
56  okapi_add_operation_for_api_entry_list_, okapi_add_operation_for_api_service_overview_,
57  okapi_add_operation_for_api_what_report_, okapi_add_operation_for_pause_all_api_,
58  okapi_add_operation_for_pause_run_api_, okapi_add_operation_for_refresh_report_scope_api_,
59  okapi_add_operation_for_refresh_reports_, okapi_add_operation_for_rerun_report_,
60  okapi_add_operation_for_resume_all_api_, okapi_add_operation_for_resume_run_api_, pause_all_api,
61  pause_run_api, refresh_report_scope_api, refresh_reports, rerun_report, resume_all_api,
62  resume_run_api,
63};
64use crate::frontend::retention::{
65  api_historical_stats, okapi_add_operation_for_api_historical_stats_,
66};
67use crate::frontend::runs::{
68  api_all_runs, api_run_current, api_run_diff, api_run_task_diffs, api_runs,
69  okapi_add_operation_for_api_all_runs_, okapi_add_operation_for_api_run_current_,
70  okapi_add_operation_for_api_run_diff_, okapi_add_operation_for_api_run_task_diffs_,
71  okapi_add_operation_for_api_runs_,
72};
73use crate::frontend::services::{
74  api_service_runtimes, api_service_workers, api_services, delete_service,
75  okapi_add_operation_for_api_service_runtimes_, okapi_add_operation_for_api_service_workers_,
76  okapi_add_operation_for_api_services_, okapi_add_operation_for_delete_service_,
77  okapi_add_operation_for_register_service_, okapi_add_operation_for_set_service_lease_,
78  register_service, set_service_lease,
79};
80use crate::frontend::sessions::{
81  api_revoke_sessions, api_sessions, okapi_add_operation_for_api_revoke_sessions_,
82  okapi_add_operation_for_api_sessions_,
83};
84
85/// The generated OpenAPI document, serialized once at mount time and served verbatim.
86struct SpecJson(String);
87
88/// Serves the generated OpenAPI 3 document (the machine-readable API contract).
89#[get("/api/openapi.json")]
90fn openapi_json(spec: &State<SpecJson>) -> (ContentType, String) {
91  (ContentType::JSON, spec.0.clone())
92}
93
94/// Agent onboarding copy for the OpenAPI `info.description` — the first thing a tool reading
95/// `/api/openapi.json` (or browsing `/api/docs`) sees. `rocket_okapi` defaults to a bare title with
96/// no description, so an agent had no in-spec orientation; this is the agent twin of the human
97/// dashboard's at-a-glance context (authentication + where to start). Rendered as Markdown by
98/// RapiDoc.
99const AGENT_API_OVERVIEW: &str = "\
100CorTeX is a distributed corpus-conversion framework for scholarly documents. This is its **agent \
101API** — the machine twin of the human admin screens: every endpoint returns the *same* structured \
102DTO a screen renders, so an agent and an operator always see identical live and historical state.\n\
103\n\
104## Authenticating\n\
105\n\
106Read-only report endpoints are public; **management and write** endpoints (and `/metrics`) are \
107**token-gated**. Supply your token either way:\n\
108\n\
109- query string — `?token=<TOKEN>`\n\
110- header — `X-Cortex-Token: <TOKEN>`\n\
111\n\
112A missing or invalid token returns `401`. Every write is attributed to an actor and recorded in the \
113operational journal.\n\
114\n\
115## Where to start\n\
116\n\
117- `GET /api/status` — at-a-glance system snapshot (corpora, the active worker fleet, the \
118pending-conversion backlog, the latest run).\n\
119- `GET /api/health` — deep health check (connection pool, dispatcher ports, corpus storage).\n\
120- `GET /api/corpora` and `GET /api/reports/<corpus>/<service>/<severity>` — the conversion report \
121hierarchy (paginated).\n\
122- `GET /api/runs` and `GET /api/runs/<corpus>/<service>/diff` — live and historical run state.\n\
123- `GET /metrics` — Prometheus gauges.\n\
124\n\
125Conversion history (`/api/runs…`) is **append-only over the API** — never deletable or mutable via \
126`/api` (pruning is a human-admin action). See `MANUAL.md` for the full operator and agent guide.";
127
128/// A small script injected into the RapiDoc page's default slot. RapiDoc's `theme` is a fixed
129/// Light/Dark with no auto mode and no toggle, so this: (1) follows the viewer's OS/browser
130/// light/dark preference, (2) adds a persistent top-right toggle button that overrides it
131/// (`localStorage`), and (3) sets a readable `primary-color` per mode — the default dark blue is
132/// too dark on a dark background, washing out links. The static gh-pages page
133/// (`scripts/build-docs-site.sh`) carries the same logic, so both surfaces behave identically.
134const RAPIDOC_THEME_SCRIPT: &str = "<script>\
135(function(){\
136var rd=document.getElementById('rapidoc');if(!rd)return;\
137var mq=window.matchMedia('(prefers-color-scheme: dark)');\
138var KEY='cortex-docs-theme';var stored=null;try{stored=localStorage.getItem(KEY);}catch(e){}\
139function theme(){return stored||(mq.matches?'dark':'light');}\
140var btn=document.createElement('button');btn.id='theme-toggle';btn.type='button';\
141btn.setAttribute('aria-label','Toggle light/dark theme');\
142btn.style.cssText='position:fixed;top:.55rem;right:.7rem;z-index:20;font:13px sans-serif;\
143padding:.3rem .6rem;border-radius:6px;border:1px solid rgba(128,128,128,.5);\
144background:rgba(128,128,128,.15);color:inherit;cursor:pointer';\
145function apply(){var t=theme();rd.setAttribute('theme',t);\
146rd.setAttribute('primary-color',t==='dark'?'#6ab0f3':'#2a5d84');\
147btn.textContent=t==='dark'?'☀ Light':'☾ Dark';}\
148btn.addEventListener('click',function(){stored=theme()==='dark'?'light':'dark';\
149try{localStorage.setItem(KEY,stored);}catch(e){}apply();});\
150document.body.appendChild(btn);apply();\
151mq.addEventListener('change',function(){if(!stored)apply();});\
152})();\
153</script>";
154
155/// Builds the agent-API routes **and** the OpenAPI 3 spec from the single `#[openapi]` handler
156/// list, then applies the nav summaries + `info` metadata. The one source of truth shared by
157/// [`mount`] (which serves both live) and [`spec_json`] (which serializes the spec for static
158/// publishing) — so the published docs can never drift from the served API.
159fn routes_and_spec() -> (Vec<rocket::Route>, rocket_okapi::okapi::openapi3::OpenApi) {
160  let settings = OpenApiSettings::default();
161  // Every `#[openapi]` agent handler is listed here; the macro returns the routes + the spec built
162  // from them. (Expand this list as more endpoints are annotated.)
163  let (routes, mut spec) = openapi_get_routes_spec![
164    settings:
165    api_corpora,
166    api_corpus,
167    api_services,
168    api_service_workers,
169    api_jobs,
170    api_job,
171    api_all_runs,
172    api_runs,
173    api_run_current,
174    api_run_diff,
175    api_run_task_diffs,
176    api_service_overview,
177    api_category_report,
178    api_what_report,
179    api_entry_list,
180    api_document,
181    api_index,
182    api_config,
183    healthz,
184    api_health,
185    register_service,
186    delete_service,
187    set_service_lease,
188    api_service_runtimes,
189    import_corpus,
190    extend_corpus,
191    export_dataset,
192    create_sandbox_corpus,
193    activate_service,
194    deactivate_service,
195    snapshot_tasks,
196    delete_corpus,
197    rerun_report,
198    pause_run_api,
199    resume_run_api,
200    pause_all_api,
201    resume_all_api,
202    refresh_reports,
203    refresh_report_scope_api,
204    reindex,
205    analyze,
206    put_config,
207    api_status,
208    api_logs,
209    api_audit,
210    api_sessions,
211    api_revoke_sessions,
212    api_historical_stats,
213  ];
214  // Give every operation a short one-line `summary` for the RapiDoc left-nav; the full doc comment
215  // stays as the `description` in the detail panel. Without a summary RapiDoc fell back to the long
216  // description, making the nav unreadable (U-2).
217  add_nav_summaries(&mut spec);
218  // The OpenAPI `info` is an agent's first contact with the API — fill in the title + an onboarding
219  // description (authentication + entry points), which `rocket_okapi` otherwise leaves bare.
220  spec.info.title = "CorTeX agent API".to_string();
221  spec.info.description = Some(AGENT_API_OVERVIEW.to_string());
222  (routes, spec)
223}
224
225/// The generated OpenAPI 3 document as pretty-printed JSON — the same bytes served live at
226/// `GET /api/openapi.json`, but obtainable **without a running server or database** (the spec is
227/// built purely from the route definitions). This is what `cortex openapi` prints and what the
228/// published docs site (`scripts/build-docs-site.sh`) bundles, so the static docs stay in lock-step
229/// with the served API.
230pub fn spec_json() -> String {
231  serde_json::to_string_pretty(&routes_and_spec().1).unwrap_or_default()
232}
233
234/// Mounts the generated agent-API documentation onto `rocket`:
235/// - the `#[openapi]`-annotated agent routes (so they exist *and* are documented from one source),
236/// - the OpenAPI 3 spec at `GET /api/openapi.json`,
237/// - a RapiDoc browser page at `GET /api/docs`.
238///
239/// The annotated routes are mounted here (not in their modules' plain route groups) so
240/// `rocket_okapi` can attach their operation metadata.
241pub fn mount(rocket: Rocket<Build>) -> Rocket<Build> {
242  let (routes, spec) = routes_and_spec();
243  let spec_json = serde_json::to_string_pretty(&spec).unwrap_or_default();
244  rocket
245    .manage(SpecJson(spec_json))
246    .mount("/", routes)
247    .mount("/", routes![openapi_json])
248    .mount(
249      "/api/docs",
250      make_rapidoc(&RapiDocConfig {
251        title: Some("CorTeX agent API".to_string()),
252        general: GeneralConfig {
253          spec_urls: vec![UrlObject::new("CorTeX API", "/api/openapi.json")],
254          ..Default::default()
255        },
256        // The left nav is a route dictionary (`METHOD /path`), not prose: predictable to scan,
257        // one line per endpoint. The descriptive summary + full description stay in the detail
258        // panel. Compact spacing keeps the (40-route) list dense.
259        nav: NavConfig {
260          use_path_in_nav_bar: true,
261          nav_item_spacing: NavItemSpacing::Compact,
262          ..Default::default()
263        },
264        // Follow the viewer's OS/browser light/dark preference (the bundled template's `theme` is
265        // otherwise fixed). The script lives in the default slot, which RapiDoc renders invisibly.
266        slots: SlotsConfig {
267          default: vec![RAPIDOC_THEME_SCRIPT.to_string()],
268          ..Default::default()
269        },
270        ..Default::default()
271      }),
272    )
273}
274
275/// Give every operation in `spec` a one-line `summary` derived from its long `description`,
276/// leaving the description intact for the detail panel. Idempotent — only fills an empty summary.
277///
278/// The summary is the operation's heading in the RapiDoc detail panel and the `summary` field in
279/// the spec (useful to OpenAPI tooling); the left **nav** is path-based (`use_path_in_nav_bar`), so
280/// it no longer depends on this text being short — hence the generous length guard in
281/// [`short_summary`] rather than the old hard nav-width cap.
282fn add_nav_summaries(spec: &mut rocket_okapi::okapi::openapi3::OpenApi) {
283  for path_item in spec.paths.values_mut() {
284    for op in [
285      path_item.get.as_mut(),
286      path_item.post.as_mut(),
287      path_item.put.as_mut(),
288      path_item.delete.as_mut(),
289      path_item.patch.as_mut(),
290    ]
291    .into_iter()
292    .flatten()
293    {
294      if op.summary.is_none()
295        && let Some(description) = op.description.as_deref()
296      {
297        op.summary = Some(short_summary(description));
298      }
299    }
300  }
301}
302
303/// A one-line summary from a long description: drops a leading `` `METHOD /path` `` code-span (the
304/// path is already shown in the nav and the detail-panel header) and markdown emphasis, then keeps
305/// the lead clause up to the first clause boundary (`. ; : —`). That yields a terse, complete
306/// heading rather than a run-on; a generous length guard only ellipsizes a pathologically long lead
307/// clause that has no early boundary.
308fn short_summary(description: &str) -> String {
309  let mut text = description.trim();
310  // Strip a leading backtick code span (e.g. "`GET /api/status`") + a following em-dash/colon/dash.
311  if let Some(after_tick) = text.strip_prefix('`')
312    && let Some(end) = after_tick.find('`')
313  {
314    let rest = after_tick[end + 1..].trim_start();
315    let rest = rest
316      .strip_prefix('—')
317      .or_else(|| rest.strip_prefix(':'))
318      .or_else(|| rest.strip_prefix('-'))
319      .unwrap_or(rest)
320      .trim_start();
321    if !rest.is_empty() {
322      text = rest;
323    }
324  }
325  // Lead clause: stop at the first sentence/clause boundary so a packed first sentence becomes a
326  // terse heading (the full text stays in the description below). Trailing punctuation + markdown
327  // emphasis are stripped.
328  let first = text
329    .split(['.', ';', ':', '—', '\n'])
330    .next()
331    .unwrap_or(text)
332    .trim()
333    .trim_end_matches([',', ';', ':']);
334  let cleaned: String = first
335    .chars()
336    .filter(|c| !matches!(c, '`' | '*' | '_'))
337    .collect();
338  let cleaned = cleaned.trim();
339  // Generous guard only: the path-based nav means this no longer has to fit a narrow nav column, so
340  // a normal first sentence passes through whole; only a runaway one is ellipsized.
341  const CAP: usize = 100;
342  if cleaned.chars().count() > CAP {
343    let truncated: String = cleaned.chars().take(CAP - 1).collect();
344    format!("{}…", truncated.trim_end())
345  } else {
346    cleaned.to_string()
347  }
348}
349
350#[cfg(test)]
351mod summary_tests {
352  use super::short_summary;
353
354  #[test]
355  fn derives_terse_summaries() {
356    // A leading "`GET /path` — …" code span is dropped (the nav + panel header already show the
357    // path).
358    assert_eq!(
359      short_summary("`GET /api/status` — the agent twin of the dashboard feed. More detail here."),
360      "the agent twin of the dashboard feed"
361    );
362    // A short plain description keeps its first sentence.
363    assert_eq!(
364      short_summary("Lists all registered corpora."),
365      "Lists all registered corpora"
366    );
367    // The summary stops at the first clause boundary (`. ; : —`), so a packed first sentence
368    // becomes a terse lead clause instead of a run-on heading (the full text stays in the
369    // description).
370    assert_eq!(
371      short_summary("Registers a corpus and starts an in-process import job; returns 202 Accepted"),
372      "Registers a corpus and starts an in-process import job"
373    );
374    assert_eq!(
375      short_summary(
376        "Inspects a single corpus: its activated services and per-service status counts"
377      ),
378      "Inspects a single corpus"
379    );
380    // A pathologically long lead clause with no early boundary (>100 chars) is ellipsized as a
381    // guard.
382    let long = "Lists every single registered corpus across the whole deployment together with all of its many associated services and their workers";
383    let summary = short_summary(long);
384    assert!(
385      summary.chars().count() <= 100 && summary.ends_with('…'),
386      "got {summary:?}"
387    );
388  }
389}