1use std::sync::OnceLock;
22
23use utoipa::OpenApi;
24
25#[derive(OpenApi)]
26#[openapi(
27 info(title = "Linkrs API", version = "1.0.0"),
28 paths(
29 crate::http::handlers::health::check,
30 crate::http::handlers::metrics::metrics,
31 crate::http::handlers::auth::login,
32 crate::http::handlers::auth::logout,
33 crate::http::handlers::auth::me,
34 crate::http::handlers::users::list,
35 crate::http::handlers::users::create,
36 crate::http::handlers::users::drop_user,
37 crate::http::handlers::users::reset_password,
38 crate::http::handlers::users::enable,
39 crate::http::handlers::users::disable,
40 crate::http::handlers::users::grant,
41 crate::http::handlers::users::revoke,
42 crate::http::handlers::session::create,
43 crate::http::handlers::session::list_sessions,
44 crate::http::handlers::session::get_session,
45 crate::http::handlers::session::delete_session,
46 crate::http::handlers::query::execute,
47 crate::http::handlers::query::validate,
48 crate::http::handlers::query::explain,
49 crate::http::handlers::query::execute_batch,
50 crate::http::handlers::transaction::begin,
51 crate::http::handlers::transaction::list_transactions,
52 crate::http::handlers::transaction::metrics,
53 crate::http::handlers::transaction::commit,
54 crate::http::handlers::transaction::rollback,
55 crate::http::handlers::transaction::kill_transaction,
56 crate::http::handlers::transaction::retry_transaction_outbox,
57 crate::http::handlers::transaction::create_savepoint,
58 crate::http::handlers::transaction::get_savepoints,
59 crate::http::handlers::transaction::rollback_to_savepoint,
60 crate::http::handlers::transaction::release_savepoint,
61 crate::http::handlers::batch::create,
62 crate::http::handlers::batch::status,
63 crate::http::handlers::batch::add_items,
64 crate::http::handlers::batch::execute,
65 crate::http::handlers::batch::cancel,
66 crate::http::handlers::batch::delete,
67 crate::http::handlers::import::import_file,
68 crate::http::handlers::import::import_status,
69 crate::http::handlers::export::export_data,
70 crate::http::handlers::statistics::query::session,
71 crate::http::handlers::statistics::query::queries,
72 crate::http::handlers::statistics::query::query_profile_detail,
73 crate::http::handlers::statistics::overview::overview,
74 crate::http::handlers::statistics::database::database,
75 crate::http::handlers::statistics::system::system,
76 crate::http::handlers::statistics::query::search,
77 crate::http::handlers::statistics::freeze::freeze_stats,
78 crate::http::handlers::statistics::freeze::trigger_freeze,
79 crate::http::handlers::statistics::migration::migration,
80 crate::http::handlers::config::get,
81 crate::http::handlers::config::update,
82 crate::http::handlers::config::get_key,
83 crate::http::handlers::config::update_key,
84 crate::http::handlers::config::reset_key,
85 crate::http::handlers::function::register,
86 crate::http::handlers::function::list,
87 crate::http::handlers::function::info,
88 crate::http::handlers::function::unregister,
89 crate::http::handlers::stream::execute_stream,
90 crate::http::handlers::cursor::open_cursor,
91 crate::http::handlers::cursor::fetch_cursor,
92 crate::http::handlers::cursor::close_cursor,
93 crate::http::handlers::sync::status,
94 crate::http::handlers::sync::retry_outbox,
95 crate::http::handlers::sync::diagnostics,
96 crate::http::handlers::sync::dead_letters,
97 crate::http::handlers::sync::requeue,
98 crate::http::handlers::sync::degraded_ranges,
99 crate::http::handlers::sync::degraded_clear,
100 crate::http::handlers::sync::retention_run,
101 crate::http::handlers::sync::retention_status,
102 crate::http::handlers::schema::space::create_space,
103 crate::http::handlers::schema::space::list_spaces,
104 crate::http::handlers::schema::space::get_space,
105 crate::http::handlers::schema::space::drop_space,
106 crate::http::handlers::schema::tag::create_tag,
107 crate::http::handlers::schema::tag::list_tags,
108 crate::http::handlers::schema::edge_type::create_edge_type,
109 crate::http::handlers::schema::edge_type::list_edge_types,
110 crate::http::handlers::schema::version::get_version_history,
111 crate::http::handlers::schema::version::get_schema_changes,
112 crate::http::handlers::schema::version::detect_breaking_changes,
113 crate::http::handlers::schema::migration::create_migration_plan,
114 crate::http::handlers::schema::migration::execute_migration,
115 crate::http::handlers::schema::migration::rollback_migration,
116 crate::http::handlers::schema::migration::dry_run_migration,
117 crate::http::handlers::schema::migration::migration_history,
118 crate::http::handlers::schema::migration::migration_status,
119 crate::http::handlers::migration_progress::migration_progress_stream,
120 crate::web::handlers::metadata::add_history,
121 crate::web::handlers::metadata::list_history,
122 crate::web::handlers::metadata::delete_history,
123 crate::web::handlers::metadata::clear_history,
124 crate::web::handlers::metadata::add_favorite,
125 crate::web::handlers::metadata::list_favorites,
126 crate::web::handlers::metadata::get_favorite,
127 crate::web::handlers::metadata::update_favorite,
128 crate::web::handlers::metadata::delete_favorite,
129 crate::web::handlers::metadata::clear_favorites,
130 crate::web::handlers::schema_ext::space::list_spaces,
131 crate::web::handlers::schema_ext::space::get_space_details,
132 crate::web::handlers::schema_ext::space::get_space_statistics,
133 crate::web::handlers::schema_ext::tag::list_tags,
134 crate::web::handlers::schema_ext::tag::create_tag,
135 crate::web::handlers::schema_ext::tag::get_tag,
136 crate::web::handlers::schema_ext::tag::update_tag,
137 crate::web::handlers::schema_ext::tag::delete_tag,
138 crate::web::handlers::schema_ext::edge_type::list_edge_types,
139 crate::web::handlers::schema_ext::edge_type::create_edge_type,
140 crate::web::handlers::schema_ext::edge_type::get_edge_type,
141 crate::web::handlers::schema_ext::edge_type::update_edge_type,
142 crate::web::handlers::schema_ext::edge_type::delete_edge_type,
143 crate::web::handlers::schema_ext::index::list_indexes,
144 crate::web::handlers::schema_ext::index::create_index,
145 crate::web::handlers::schema_ext::index::get_index,
146 crate::web::handlers::schema_ext::index::delete_index,
147 crate::web::handlers::schema_ext::index::rebuild_index,
148 crate::web::handlers::data_browser::list_vertices_by_tag,
149 crate::web::handlers::data_browser::list_edges_by_type,
150 crate::web::handlers::graph_data::get_vertex,
151 crate::web::handlers::graph_data::get_edge,
152 crate::web::handlers::graph_data::get_neighbors,
153 ),
154 tags(
155 (name = "WebHistory", description = "Web query history and favorites"),
156 (name = "WebSchema", description = "Web extended schema management"),
157 (name = "WebData", description = "Web data browsing"),
158 (name = "WebGraph", description = "Web graph data queries"),
159 (name = "Health", description = "Health checks"),
160 (name = "Auth", description = "Authentication"),
161 (name = "Session", description = "Session management"),
162 (name = "Query", description = "Query execution"),
163 (name = "Transaction", description = "Transaction management"),
164 (name = "Batch", description = "Batch operations"),
165 (name = "Import", description = "Data import"),
166 (name = "Export", description = "Data export"),
167 (name = "Statistics", description = "Statistics and monitoring"),
168 (name = "Config", description = "Configuration management"),
169 (name = "Function", description = "Custom functions"),
170 (name = "Stream", description = "Streaming queries"),
171 (name = "Sync", description = "Synchronization management"),
172 (name = "Schema", description = "Schema management"),
173 (name = "Migration", description = "Schema migration"),
174 )
175)]
176struct CoreDoc;
177
178#[cfg(feature = "vector")]
179#[derive(OpenApi)]
180#[openapi(
181 info(title = "Linkrs API", version = "1.0.0"),
182 paths(
183 crate::http::handlers::vector::create_index,
184 crate::http::handlers::vector::list_indexes,
185 crate::http::handlers::vector::get_index_info,
186 crate::http::handlers::vector::drop_index,
187 crate::http::handlers::vector::search,
188 crate::http::handlers::vector::get_vector,
189 crate::http::handlers::vector::count,
190 crate::http::handlers::vector::set_payload,
191 crate::http::handlers::vector::set_payload_fields,
192 crate::http::handlers::vector::delete_payload,
193 crate::http::handlers::vector::scroll,
194 crate::http::handlers::rebuild::rebuild_vector,
195 crate::http::handlers::rebuild::vector_rebuild_status,
196 crate::http::handlers::rebuild::clear_vector,
197 ),
198 components(schemas(
199 crate::core::vector::VectorFilter,
200 crate::core::vector::MinShouldCondition,
201 crate::core::vector::FilterCondition,
202 crate::core::vector::ConditionType,
203 crate::core::vector::RangeCondition,
204 crate::core::vector::GeoRadius,
205 crate::core::vector::GeoBoundingBox,
206 crate::core::vector::ValuesCountCondition,
207 )),
208 tags((name = "Vector", description = "Vector search and index management"))
209)]
210struct VectorDoc;
211
212#[cfg(feature = "fulltext")]
213#[derive(OpenApi)]
214#[openapi(
215 info(title = "Linkrs API", version = "1.0.0"),
216 paths(
217 crate::http::handlers::rebuild::rebuild_fulltext,
218 crate::http::handlers::rebuild::fulltext_rebuild_status,
219 crate::http::handlers::rebuild::clear_fulltext,
220 crate::http::handlers::rebuild::inconsistent_fulltext,
221 ),
222 tags((name = "Fulltext", description = "Fulltext index management"))
223)]
224struct FulltextDoc;
225
226pub fn openapi_json() -> String {
228 static CACHE: OnceLock<String> = OnceLock::new();
229 CACHE
230 .get_or_init(|| {
231 #[allow(unused_mut)]
232 let mut doc = CoreDoc::openapi();
233 #[cfg(feature = "vector")]
234 {
235 doc = doc.merge_from(VectorDoc::openapi());
236 }
237 #[cfg(feature = "fulltext")]
238 {
239 doc = doc.merge_from(FulltextDoc::openapi());
240 }
241 serde_json::to_string_pretty(&doc).expect("OpenAPI document must serialize")
242 })
243 .clone()
244}
245
246#[cfg(all(feature = "vector", feature = "fulltext"))]
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use std::collections::BTreeSet;
251 use std::path::{Path, PathBuf};
252
253 const SNAPSHOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../frontend/openapi.json");
256 const REFRESH_ENV: &str = "LINKRS_REFRESH_OPENAPI";
257
258 fn source_root() -> PathBuf {
259 Path::new(env!("CARGO_MANIFEST_DIR")).join("src")
260 }
261
262 fn route_prefix(rel: &str) -> Option<String> {
268 match rel {
269 "http/router.rs" => Some("/v1".to_string()),
270 "web/handlers/metadata.rs" => Some("/api/v1/queries".to_string()),
271 "web/handlers/schema_ext/routes.rs" => Some("/api/v1/schema".to_string()),
272 "web/handlers/data_browser.rs" => Some("/api/v1/data".to_string()),
273 "web/handlers/graph_data.rs" => Some("/api/v1/graph".to_string()),
274 _ => None,
275 }
276 }
277
278 fn is_ignored_path(path: &str) -> bool {
280 path.contains("/api-docs")
281 }
282
283 fn feature_enabled(name: &str) -> bool {
284 match name {
285 "vector" => cfg!(feature = "vector"),
286 "fulltext" => cfg!(feature = "fulltext"),
287 _ => panic!("unexpected feature gate in scanned route sources: {name}"),
288 }
289 }
290
291 #[test]
292 fn openapi_snapshot_matches() {
293 let doc = openapi_json();
294 if std::env::var(REFRESH_ENV).as_deref() == Ok("1") {
295 std::fs::write(SNAPSHOT, format!("{doc}\n")).expect("snapshot must be writable");
296 return;
297 }
298 let expected = std::fs::read_to_string(SNAPSHOT).expect("openapi snapshot must exist");
301 assert_eq!(
302 doc.trim_end(),
303 expected.trim_end(),
304 "snapshot drifted; refresh with {REFRESH_ENV}=1"
305 );
306 }
307
308 #[test]
309 fn routes_match_openapi_paths() {
310 let routed = collect_routed();
311 let annotated = collect_annotated();
312 let documented = collect_documented();
313
314 assert_sets_equal(
315 &routed,
316 &annotated,
317 "router registrations vs #[utoipa::path] annotations",
318 );
319 assert_sets_equal(
320 &annotated,
321 &documented,
322 "#[utoipa::path] annotations vs ApiDoc registration",
323 );
324 assert!(!routed.is_empty(), "expected a non-empty route set");
325 }
326
327 #[test]
331 fn operation_ids_are_unique() {
332 use std::collections::HashSet;
333 let doc: serde_json::Value =
334 serde_json::from_str(&openapi_json()).expect("document must parse");
335 let mut seen = HashSet::new();
336 for (_path, item) in doc["paths"]
337 .as_object()
338 .expect("document must contain paths")
339 {
340 if let Some(ops) = item.as_object() {
341 for (_method, op) in ops {
342 if let Some(id) = op.get("operationId").and_then(|id| id.as_str()) {
343 assert!(seen.insert(id.to_string()), "duplicate operationId: {id}");
344 }
345 }
346 }
347 }
348 }
349
350 const HTTP_METHODS: [&str; 8] = [
353 "get", "put", "post", "patch", "delete", "head", "options", "trace",
354 ];
355
356 fn assert_sets_equal(
357 left: &BTreeSet<(String, String)>,
358 right: &BTreeSet<(String, String)>,
359 label: &str,
360 ) {
361 let only_left: Vec<_> = left.difference(right).collect();
362 let only_right: Vec<_> = right.difference(left).collect();
363 assert!(
364 only_left.is_empty() && only_right.is_empty(),
365 "{label} drift\nonly on the left: {only_left:?}\nonly on the right: {only_right:?}"
366 );
367 }
368
369 fn walk_rs(dir: &Path) -> Vec<PathBuf> {
370 let mut out = Vec::new();
371 for entry in std::fs::read_dir(dir).expect("read source dir") {
372 let path = entry.expect("dir entry").path();
373 if path.is_dir() {
374 out.extend(walk_rs(&path));
375 } else if path.extension().is_some_and(|e| e == "rs") {
376 out.push(path);
377 }
378 }
379 out
380 }
381
382 fn source_files() -> Vec<(String, String)> {
383 let root = source_root();
384 walk_rs(&root)
385 .into_iter()
386 .map(|path| {
387 let rel = path
388 .strip_prefix(&root)
389 .expect("source under src root")
390 .to_string_lossy()
391 .replace('\\', "/");
392 let text = std::fs::read_to_string(&path).expect("read source file");
393 (rel, text)
394 })
395 .collect()
396 }
397
398 fn collect_routed() -> BTreeSet<(String, String)> {
399 let mut out = BTreeSet::new();
400 for (rel, text) in source_files() {
401 let Some(prefix) = route_prefix(&rel) else {
402 continue;
403 };
404 for (method, path) in parse_route_registrations(&text) {
405 let full = format!("{prefix}{path}");
406 if !is_ignored_path(&full) {
407 out.insert((method, full));
408 }
409 }
410 }
411 out
412 }
413
414 fn collect_annotated() -> BTreeSet<(String, String)> {
415 let mut out = BTreeSet::new();
416 for (_rel, text) in source_files() {
417 for (method, path) in parse_utoipa_annotations(&text) {
418 out.insert((method, path));
419 }
420 }
421 out
422 }
423
424 fn collect_documented() -> BTreeSet<(String, String)> {
425 let doc: serde_json::Value =
426 serde_json::from_str(&openapi_json()).expect("document must parse");
427 let mut out = BTreeSet::new();
428 if let Some(paths) = doc.get("paths").and_then(|p| p.as_object()) {
429 for (path, item) in paths {
430 if let Some(ops) = item.as_object() {
431 for method in ops.keys() {
432 out.insert((method.to_uppercase(), path.clone()));
433 }
434 }
435 }
436 }
437 out
438 }
439
440 fn parse_utoipa_annotations(text: &str) -> Vec<(String, String)> {
442 let mut out = Vec::new();
443 let mut lines = text.lines().peekable();
444 let mut buf: Vec<String> = Vec::new();
445 while let Some(line) = lines.next() {
446 let trimmed = line.trim();
447 if let Some(tail) = trimmed.strip_prefix("#[utoipa::path(") {
448 buf.clear();
449 if tail != "(" {
450 buf.push(tail.to_string());
451 }
452 for line in lines.by_ref().take(60) {
453 let trimmed = line.trim();
454 if let Some(closer) = trimmed.strip_prefix(")]") {
455 buf.push(trimmed.to_string());
456 let _ = closer;
457 break;
458 }
459 buf.push(trimmed.to_string());
460 }
461 if let Some((method, path)) = annotation_head(&buf) {
462 out.push((method, path));
463 }
464 }
465 }
466 out
467 }
468
469 fn annotation_head(buf: &[String]) -> Option<(String, String)> {
470 let method = buf.iter().find_map(|line| {
471 HTTP_METHODS
472 .iter()
473 .find(|m| line.starts_with(&format!("{m},")))
474 .map(|m| m.to_uppercase())
475 })?;
476 let path = buf
477 .iter()
478 .find_map(|line| {
479 line.find("path = \"")
480 .map(|i| &line[i + "path = \"".len()..])
481 })
482 .and_then(|rest| rest.split('"').next())
483 .map(str::to_string)?;
484 Some((method, path))
485 }
486
487 fn parse_route_registrations(text: &str) -> Vec<(String, String)> {
492 let mut out = Vec::new();
493 let mut pending_attr: Option<String> = None;
494 let mut fn_active = true;
495 let mut entry: Option<(Option<String>, Vec<String>)> = None;
496
497 let flush = |out: &mut Vec<(String, String)>,
498 entry: &mut Option<(Option<String>, Vec<String>)>| {
499 if let Some((Some(path), methods)) = entry.take() {
500 for method in methods {
501 out.push((method, path.clone()));
502 }
503 } else {
504 entry.take();
505 }
506 };
507
508 for line in text.lines() {
509 let trimmed = line.trim();
510 let top_level = !trimmed.is_empty() && !line.starts_with(char::is_whitespace);
511 if top_level {
512 if let Some(expr) = trimmed
513 .strip_prefix("#[cfg(")
514 .and_then(|rest| rest.strip_suffix(")]"))
515 {
516 pending_attr = Some(expr.to_string());
517 continue;
518 }
519 if trimmed.starts_with("#[") || trimmed.starts_with("//") {
520 continue;
521 }
522 let is_fn = trimmed.starts_with("fn ")
523 || trimmed.starts_with("pub fn ")
524 || trimmed.starts_with("pub(crate) fn ")
525 || trimmed.starts_with("async fn ")
526 || trimmed.starts_with("pub async fn ")
527 || trimmed.starts_with("pub(crate) async fn ");
528 if is_fn {
529 fn_active = pending_attr.take().map_or(true, |expr| eval_cfg(&expr));
530 entry = None;
531 continue;
532 }
533 pending_attr = None;
534 }
535 if !fn_active {
536 continue;
537 }
538
539 if let Some(pos) = line.find(".route(") {
540 flush(&mut out, &mut entry);
541 let tail = &line[pos + ".route(".len()..];
542 entry = Some((first_string_literal(tail), method_tokens(tail)));
543 } else if let Some((path, methods)) = entry.as_mut() {
544 if path.is_none() {
545 *path = first_string_literal(trimmed);
546 }
547 methods.extend(method_tokens(trimmed));
548 }
549
550 let ends_entry = trimmed.starts_with(".nest(")
551 || trimmed.starts_with(".layer(")
552 || trimmed.starts_with(".route_layer(")
553 || trimmed.starts_with(".merge(")
554 || trimmed.starts_with(".fallback(")
555 || trimmed.starts_with(".with_state(")
556 || trimmed == ")"
557 || trimmed == "),"
558 || trimmed.ends_with(");");
559 if ends_entry {
560 flush(&mut out, &mut entry);
561 }
562 }
563 flush(&mut out, &mut entry);
564 out
565 }
566
567 fn first_string_literal(s: &str) -> Option<String> {
568 let start = s.find('"')?;
569 let rest = &s[start + 1..];
570 let end = rest.find('"')?;
571 Some(rest[..end].to_string())
572 }
573
574 fn method_tokens(line: &str) -> Vec<String> {
575 let cleaned = strip_string_literals(line);
576 let b = cleaned.as_bytes();
577 let mut out = Vec::new();
578 let mut i = 0;
579 while i < b.len() {
580 if !(b[i].is_ascii_alphabetic() || b[i] == b'_') {
581 i += 1;
582 continue;
583 }
584 let start = i;
585 while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
586 i += 1;
587 }
588 let word = &cleaned[start..i];
589 let mut j = i;
590 while j < b.len() && (b[j] as char).is_whitespace() {
591 j += 1;
592 }
593 if j < b.len() && b[j] == b'(' && HTTP_METHODS.contains(&word) {
594 out.push(word.to_uppercase());
595 }
596 }
597 out
598 }
599
600 fn strip_string_literals(line: &str) -> String {
601 let mut out = String::with_capacity(line.len());
602 let mut in_string = false;
603 for c in line.chars() {
604 if c == '"' {
605 in_string = !in_string;
606 out.push(' ');
607 } else if in_string {
608 out.push(' ');
609 } else {
610 out.push(c);
611 }
612 }
613 out
614 }
615
616 fn eval_cfg(expr: &str) -> bool {
617 let expr = expr.trim();
618 if let Some(inner) = expr.strip_prefix("not(").and_then(|s| s.strip_suffix(')')) {
619 return !eval_cfg(inner);
620 }
621 if let Some(inner) = expr.strip_prefix("all(").and_then(|s| s.strip_suffix(')')) {
622 return split_cfg_items(inner).iter().all(|item| eval_cfg(item));
623 }
624 if let Some(inner) = expr.strip_prefix("any(").and_then(|s| s.strip_suffix(')')) {
625 return split_cfg_items(inner).iter().any(|item| eval_cfg(item));
626 }
627 match expr {
628 "debug_assertions" => cfg!(debug_assertions),
629 "test" => false,
630 other => {
631 if let Some(name) = other
632 .strip_prefix("feature")
633 .and_then(|s| s.trim().strip_prefix('='))
634 .and_then(|s| s.trim().split('"').nth(1))
635 {
636 feature_enabled(name)
637 } else {
638 panic!("unsupported cfg predicate: {expr}")
639 }
640 }
641 }
642 }
643
644 fn split_cfg_items(expr: &str) -> Vec<&str> {
645 let mut depth = 0usize;
646 let mut items = Vec::new();
647 let mut start = 0;
648 for (i, c) in expr.char_indices() {
649 match c {
650 '(' => depth += 1,
651 ')' => depth -= 1,
652 ',' if depth == 0 => {
653 items.push(expr[start..i].trim());
654 start = i + 1;
655 }
656 _ => {}
657 }
658 }
659 let last = expr[start..].trim();
660 if !last.is_empty() {
661 items.push(last);
662 }
663 items
664 }
665}