1use 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
27use 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
85struct SpecJson(String);
87
88#[get("/api/openapi.json")]
90fn openapi_json(spec: &State<SpecJson>) -> (ContentType, String) {
91 (ContentType::JSON, spec.0.clone())
92}
93
94const 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
128const 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
155fn routes_and_spec() -> (Vec<rocket::Route>, rocket_okapi::okapi::openapi3::OpenApi) {
160 let settings = OpenApiSettings::default();
161 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 add_nav_summaries(&mut spec);
218 spec.info.title = "CorTeX agent API".to_string();
221 spec.info.description = Some(AGENT_API_OVERVIEW.to_string());
222 (routes, spec)
223}
224
225pub fn spec_json() -> String {
231 serde_json::to_string_pretty(&routes_and_spec().1).unwrap_or_default()
232}
233
234pub 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 nav: NavConfig {
260 use_path_in_nav_bar: true,
261 nav_item_spacing: NavItemSpacing::Compact,
262 ..Default::default()
263 },
264 slots: SlotsConfig {
267 default: vec![RAPIDOC_THEME_SCRIPT.to_string()],
268 ..Default::default()
269 },
270 ..Default::default()
271 }),
272 )
273}
274
275fn 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
303fn short_summary(description: &str) -> String {
309 let mut text = description.trim();
310 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 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 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 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 assert_eq!(
364 short_summary("Lists all registered corpora."),
365 "Lists all registered corpora"
366 );
367 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 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}