1use std::collections::BTreeMap;
30use std::path::{Path as FsPath, PathBuf};
31use std::sync::Arc;
32
33use axum::extract::{Extension, Path as UrlPath, Query, Request, State};
34use axum::http::header::CONTENT_TYPE;
35use axum::routing::{get, post};
36use axum::{Json, Router};
37use serde::{Deserialize, Serialize};
38use serde_json::{json, Value};
39
40use codetopo_cli::{
41 default_package_name, describe_schema, index_repo, load_graph_from_db, render_path,
42 require_node, CliError,
43};
44use codetopo_core::graph::{Graph, PathStep};
45use codetopo_core::schema::{EdgeKind, NodeKind};
46use codetopo_core::snapshot::Snapshot;
47use codetopo_store::Store;
48
49use crate::auth::{KeyId, KeyPlan};
50use crate::config::Config;
51use crate::error::ApiError;
52use crate::ingest;
53use crate::keys::now_unix_secs;
54use crate::limits::{plan_limits, Envelope};
55use crate::tenancy::Tenancy;
56
57const DEFAULT_LIMIT: usize = 20;
59
60const MAX_LIMIT: usize = 1000;
65
66#[derive(Debug, Clone)]
80pub struct AppState {
81 config: Arc<Config>,
82 keys: Arc<crate::keys::KeyStore>,
83 tenancy: Arc<Tenancy>,
84 envelope: Arc<Envelope>,
85}
86
87impl AppState {
88 pub fn new(
96 config: Arc<Config>,
97 keys: Arc<crate::keys::KeyStore>,
98 tenancy: Arc<Tenancy>,
99 ) -> Self {
100 AppState {
101 config,
102 keys,
103 tenancy,
104 envelope: Arc::new(Envelope::new()),
105 }
106 }
107
108 fn db_path(&self, index_id: &str) -> PathBuf {
110 self.config.index_db_path(index_id)
111 }
112}
113
114fn enforce_tenancy(state: &AppState, index_id: &str, key: &KeyId) -> Result<(), ApiError> {
131 let Some(record) = state.tenancy.lookup(index_id)? else {
132 if crate::tenancy::ownerless_readable(
133 state.config.deny_ownerless_indexes,
134 state.keys.active_len(),
135 ) {
136 return Ok(());
137 }
138 return Err(ApiError::unknown_index(index_id));
139 };
140 if record.is_expired(now_unix_secs()) {
141 state.tenancy.remove(index_id)?;
142 state.config.remove_index_files(index_id);
145 return Err(ApiError::unknown_index(index_id));
146 }
147 if record.owner_key_id != key.as_str() {
148 return Err(ApiError::unknown_index(index_id));
149 }
150 Ok(())
151}
152
153pub fn router(state: AppState) -> Router {
162 let envelope = state.envelope.clone();
168 let queries = Router::new()
169 .route("/v1/graphs/:id/descendants", get(descendants))
170 .route("/v1/graphs/:id/ancestors", get(ancestors))
171 .route("/v1/graphs/:id/blast-radius", get(blast_radius))
172 .route("/v1/graphs/:id/path", get(path))
173 .route("/v1/graphs/:id/stats", get(stats))
174 .route("/v1/graphs/:id/verify", get(verify))
175 .route("/v1/graphs/:id/snapshot", get(snapshot))
176 .route("/v1/graphs/:id/neighborhood", get(neighborhood))
177 .route("/v1/graphs/:id/symbol", get(symbol))
178 .route("/v1/graphs/:id/search", get(search))
179 .route("/v1/graphs/:id/diagnostics", get(diagnostics))
180 .route("/v1/graphs/:id/rank", get(rank))
181 .route("/v1/graphs/:id/diff", get(diff))
182 .route("/v1/schema", get(schema))
183 .route_layer(axum::middleware::from_fn_with_state(
184 envelope,
185 crate::limits::query_envelope,
186 ));
187 Router::new()
188 .route("/v1/index", post(index))
189 .merge(queries)
190 .with_state(state)
191}
192
193#[derive(Debug, Default, Deserialize)]
206struct IndexRequest {
207 repo_path: Option<String>,
208 repo_url: Option<String>,
209 package: Option<String>,
210}
211
212#[derive(Debug, Default, Deserialize)]
216struct IndexQuery {
217 package: Option<String>,
218}
219
220const JSON_BODY_LIMIT: usize = 2 * 1024 * 1024;
231
232fn non_empty(field: Option<String>) -> Option<String> {
235 field
236 .map(|raw| raw.trim().to_string())
237 .filter(|raw| !raw.is_empty())
238}
239
240async fn read_body(request: Request, limit: usize, failure: &str) -> Result<axum::body::Bytes, ApiError> {
243 axum::body::to_bytes(request.into_body(), limit)
244 .await
245 .map_err(|_| ApiError::invalid_body(failure))
246}
247
248async fn index(
292 State(state): State<AppState>,
293 Extension(key): Extension<KeyId>,
294 Extension(plan): Extension<KeyPlan>,
295 Query(query): Query<IndexQuery>,
296 request: Request,
297) -> Result<Json<Value>, ApiError> {
298 let content_type = request
299 .headers()
300 .get(CONTENT_TYPE)
301 .and_then(|value| value.to_str().ok())
302 .map(|value| {
303 value
304 .split(';')
305 .next()
306 .unwrap_or("")
307 .trim()
308 .to_ascii_lowercase()
309 })
310 .filter(|value| !value.is_empty());
311
312 let index_id = uuid::Uuid::new_v4().to_string();
316 let db = state.db_path(&index_id);
317
318 let limits = plan_limits(plan.as_str());
322 let _index_permit = match limits.max_concurrent_index_ops {
323 Some(max_ops) => match state.envelope.try_acquire_index_op(key.as_str(), max_ops) {
324 Some(permit) => Some(permit),
325 None => return Err(ApiError::index_concurrency(plan.as_str(), max_ops)),
326 },
327 None => None,
328 };
329 if let Some(max_active) = limits.max_active_indexes {
330 let current = state
331 .tenancy
332 .count_active_for_owner(key.as_str(), now_unix_secs())?;
333 if current >= max_active {
334 return Err(ApiError::index_quota(plan.as_str(), max_active, current));
335 }
336 }
337 let caps = limits.ingest;
338
339 let (repo, package, workdir) = if content_type.as_deref() == Some("application/json") {
343 let bytes = read_body(
344 request,
345 JSON_BODY_LIMIT,
346 "request body could not be read (2 MiB limit)",
347 )
348 .await?;
349 let body: IndexRequest = serde_json::from_slice(&bytes).map_err(|err| {
350 ApiError::invalid_body(&format!("not valid JSON for this route: {err}"))
351 })?;
352 match (non_empty(body.repo_path), non_empty(body.repo_url)) {
353 (Some(_), Some(_)) => {
354 return Err(ApiError::invalid_body(
355 "repo_path and repo_url are alternatives; send exactly one",
356 ));
357 }
358 (None, None) => {
359 return Err(ApiError::invalid_repo_path(
360 "one of repo_path or repo_url is required",
361 ));
362 }
363 (Some(raw_path), None) => {
364 if !plan.is_self_hosted() {
365 return Err(ApiError::invalid_repo_path(
366 "repo_path names a path on the server host and is available \
367 only to self-hosted keys; use repo_url or an archive upload",
368 ));
369 }
370 let repo = PathBuf::from(raw_path);
371 if !repo.is_dir() {
372 return Err(ApiError::invalid_repo_path(&format!(
373 "not a directory: {}",
374 repo.display()
375 )));
376 }
377 let package =
378 non_empty(body.package).unwrap_or_else(|| default_package_name(&repo));
379 (repo, package, None)
380 }
381 (None, Some(raw_url)) => {
382 let validated = ingest::validate_repo_url(&raw_url).await?;
383 let workdir = state.config.index_workdir_path(&index_id);
384 let clone_dir = workdir.clone();
385 let clone_validated = validated.clone();
386 tokio::task::spawn_blocking(move || {
387 ingest::clone_into(&clone_validated, &clone_dir, caps)
388 })
389 .await
390 .map_err(|err| ApiError::internal(format!("clone task failed: {err}")))??;
391 let package =
392 non_empty(body.package).unwrap_or_else(|| validated.package_name());
393 (workdir.clone(), package, Some(workdir))
394 }
395 }
396 } else {
397 let bytes = read_body(
398 request,
399 caps.max_upload_bytes,
400 "archive body could not be read or exceeds the upload limit",
401 )
402 .await?;
403 let kind = ingest::archive_kind(content_type.as_deref(), &bytes).ok_or_else(|| {
404 ApiError::invalid_body(
405 "unrecognized archive body: send a .zip or .tar.gz archive",
406 )
407 })?;
408 let workdir = state.config.index_workdir_path(&index_id);
409 let extract_dir = workdir.clone();
410 let root = tokio::task::spawn_blocking(move || {
411 ingest::extract_into(kind, &bytes, &extract_dir, caps)
412 })
413 .await
414 .map_err(|err| ApiError::internal(format!("extract task failed: {err}")))??;
415 let package = non_empty(query.package).unwrap_or_else(|| {
416 if root != workdir {
417 default_package_name(&root)
418 } else {
419 "repo".to_string()
420 }
421 });
422 (root, package, Some(workdir))
423 };
424
425 if let Some(max_files) = limits.max_files_per_index {
432 let count_root = repo.clone();
433 let counted = tokio::task::spawn_blocking(move || {
434 ingest::count_tree_files(&count_root, max_files as u64 + 1)
435 })
436 .await
437 .map_err(|err| ApiError::internal(format!("file count task failed: {err}")))?;
438 if counted > max_files as u64 {
439 if let Some(workdir) = &workdir {
440 let _ = std::fs::remove_dir_all(workdir);
441 }
442 return Err(ApiError::file_quota(plan.as_str(), max_files));
443 }
444 }
445
446 let report = tokio::task::spawn_blocking(move || index_repo(&repo, &db, &package, None, None))
447 .await
448 .map_err(|err| ApiError::internal(format!("index task failed: {err}")))?;
449 let report = match report {
450 Ok(report) => report,
451 Err(err) => {
452 let _ = std::fs::remove_file(state.db_path(&index_id));
456 if let Some(workdir) = &workdir {
457 let _ = std::fs::remove_dir_all(workdir);
458 }
459 return Err(ApiError::internal(format!("cannot index repository: {err}")));
460 }
461 };
462
463 let created_at = now_unix_secs();
464 let expires_at = state
465 .config
466 .index_ttl_secs
467 .map(|ttl| created_at + ttl as i64);
468 if let Err(err) = state
469 .tenancy
470 .record(&index_id, key.as_str(), created_at, expires_at)
471 {
472 let _ = std::fs::remove_file(state.db_path(&index_id));
473 if let Some(workdir) = &workdir {
474 let _ = std::fs::remove_dir_all(workdir);
475 }
476 return Err(err);
477 }
478
479 Ok(Json(json!({
480 "index_id": index_id,
481 "files_indexed": report.files_indexed,
482 "files_failed": report.files_failed,
483 "nodes": report.nodes,
484 "edges": report.edges,
485 "diagnostics": report.diagnostics,
486 })))
487}
488
489#[derive(Debug, Default, Deserialize)]
499struct ClosureQuery {
500 node: Option<String>,
501 expand: Option<String>,
502 limit: Option<String>,
503}
504
505#[derive(Debug)]
507struct ClosureParams {
508 node: String,
509 expand: bool,
510 limit: usize,
511}
512
513#[derive(Debug, Clone, Copy)]
515enum Direction {
516 Downstream,
518 Upstream,
520}
521
522fn closure_params(query: &ClosureQuery) -> Result<ClosureParams, ApiError> {
528 let node = query
529 .node
530 .as_deref()
531 .map(str::trim)
532 .filter(|raw| !raw.is_empty())
533 .ok_or_else(|| ApiError::missing_param("node"))?
534 .to_string();
535
536 let expand = match query.expand.as_deref().map(str::trim) {
537 None | Some("") => false,
538 Some("true") => true,
539 Some("false") => false,
540 Some(other) => {
541 return Err(ApiError::invalid_param(
542 "expand",
543 &format!("expected 'true' or 'false', got '{other}'"),
544 ))
545 }
546 };
547
548 let limit = match query.limit.as_deref().map(str::trim) {
549 None | Some("") => DEFAULT_LIMIT,
550 Some(raw) => {
551 let parsed: usize = raw.parse().map_err(|_| {
552 ApiError::invalid_param("limit", &format!("expected an integer, got '{raw}'"))
553 })?;
554 if parsed == 0 || parsed > MAX_LIMIT {
555 return Err(ApiError::invalid_param(
556 "limit",
557 &format!("must be 1..={MAX_LIMIT}, got {parsed}"),
558 ));
559 }
560 parsed
561 }
562 };
563
564 Ok(ClosureParams {
565 node,
566 expand,
567 limit,
568 })
569}
570
571#[derive(Debug, Serialize)]
577struct ClosureResponse {
578 index_id: String,
579 relation: &'static str,
580 node: String,
581 total: usize,
582 expand: bool,
583 limit: usize,
584 truncated: bool,
585 nodes: Vec<String>,
586}
587
588async fn descendants(
593 State(state): State<AppState>,
594 Extension(key): Extension<KeyId>,
595 UrlPath(index_id): UrlPath<String>,
596 Query(query): Query<ClosureQuery>,
597) -> Result<Json<ClosureResponse>, ApiError> {
598 closure(state, &key, index_id, query, "descendants", Direction::Downstream).await
599}
600
601async fn ancestors(
605 State(state): State<AppState>,
606 Extension(key): Extension<KeyId>,
607 UrlPath(index_id): UrlPath<String>,
608 Query(query): Query<ClosureQuery>,
609) -> Result<Json<ClosureResponse>, ApiError> {
610 closure(state, &key, index_id, query, "ancestors", Direction::Upstream).await
611}
612
613async fn blast_radius(
618 State(state): State<AppState>,
619 Extension(key): Extension<KeyId>,
620 UrlPath(index_id): UrlPath<String>,
621 Query(query): Query<ClosureQuery>,
622) -> Result<Json<ClosureResponse>, ApiError> {
623 closure(
624 state,
625 &key,
626 index_id,
627 query,
628 "blast_radius",
629 Direction::Downstream,
630 )
631 .await
632}
633
634async fn closure(
647 state: AppState,
648 key: &KeyId,
649 index_id: String,
650 query: ClosureQuery,
651 relation: &'static str,
652 direction: Direction,
653) -> Result<Json<ClosureResponse>, ApiError> {
654 enforce_tenancy(&state, &index_id, key)?;
655 let ClosureParams {
656 node,
657 expand,
658 limit,
659 } = closure_params(&query)?;
660 let db = state.db_path(&index_id);
661 let graph = load_graph_blocking(&db, &index_id)?;
662
663 let walk_node = node.clone();
668 let ids = spawn_blocking(move || {
669 Ok(match direction {
670 Direction::Downstream if relation == "blast_radius" => graph.blast_radius(&walk_node),
671 Direction::Downstream => graph.descendants(&walk_node),
672 Direction::Upstream => graph.ancestors(&walk_node),
673 })
674 })
675 .await?;
676
677 let total = ids.len();
678 let truncated = !expand && total > limit;
679 let shown = if expand {
680 ids
681 } else {
682 ids[..total.min(limit)].to_vec()
683 };
684
685 Ok(Json(ClosureResponse {
686 index_id,
687 relation,
688 node,
689 total,
690 expand,
691 limit,
692 truncated,
693 nodes: shown,
694 }))
695}
696
697#[derive(Debug, Default, Deserialize)]
705struct PathQuery {
706 from: Option<String>,
707 to: Option<String>,
708}
709
710#[derive(Debug, Serialize)]
717struct PathResponse {
718 index_id: String,
719 from: String,
720 to: String,
721 steps: Vec<PathStep>,
722 rendered: String,
723}
724
725async fn path(
726 State(state): State<AppState>,
727 Extension(key): Extension<KeyId>,
728 UrlPath(index_id): UrlPath<String>,
729 Query(query): Query<PathQuery>,
730) -> Result<Json<PathResponse>, ApiError> {
731 enforce_tenancy(&state, &index_id, &key)?;
732 let from = required_param(query.from.as_deref(), "from")?;
733 let to = required_param(query.to.as_deref(), "to")?;
734 let db = state.db_path(&index_id);
735 let graph = load_graph_blocking(&db, &index_id)?;
736
737 let steps = graph
738 .path(&from, &to)
739 .ok_or_else(|| ApiError::no_path(&from, &to))?;
740 let rendered = render_path(&steps);
741
742 Ok(Json(PathResponse {
743 index_id,
744 from,
745 to,
746 steps,
747 rendered,
748 }))
749}
750
751fn required_param(raw: Option<&str>, name: &str) -> Result<String, ApiError> {
752 raw.map(str::trim)
753 .filter(|value| !value.is_empty())
754 .map(str::to_string)
755 .ok_or_else(|| ApiError::missing_param(name))
756}
757
758#[derive(Debug, Serialize)]
768struct StatsResponse {
769 index_id: String,
770 nodes: KindCounts,
771 edges: KindCounts,
772}
773
774#[derive(Debug, Serialize)]
775struct KindCounts {
776 total: usize,
777 by_kind: BTreeMap<String, usize>,
778}
779
780async fn stats(
781 State(state): State<AppState>,
782 Extension(key): Extension<KeyId>,
783 UrlPath(index_id): UrlPath<String>,
784) -> Result<Json<StatsResponse>, ApiError> {
785 enforce_tenancy(&state, &index_id, &key)?;
786 let db = state.db_path(&index_id);
787 let graph = load_graph_blocking(&db, &index_id)?;
788
789 let mut node_kinds = BTreeMap::new();
790 for kind in ALL_NODE_KINDS {
791 let count = graph.nodes().filter(|node| node.kind == kind).count();
792 if count > 0 {
793 node_kinds.insert(node_kind_name(kind).to_string(), count);
794 }
795 }
796
797 let mut edge_kinds = BTreeMap::new();
798 for kind in ALL_EDGE_KINDS {
799 let count = graph.edge_count_by_kind(kind);
800 if count > 0 {
801 edge_kinds.insert(kind.as_str().to_string(), count);
802 }
803 }
804
805 Ok(Json(StatsResponse {
806 index_id,
807 nodes: KindCounts {
808 total: graph.node_count(),
809 by_kind: node_kinds,
810 },
811 edges: KindCounts {
812 total: graph.edge_count(),
813 by_kind: edge_kinds,
814 },
815 }))
816}
817
818const ALL_NODE_KINDS: [NodeKind; 10] = [
820 NodeKind::Package,
821 NodeKind::Module,
822 NodeKind::File,
823 NodeKind::Class,
824 NodeKind::Interface,
825 NodeKind::Function,
826 NodeKind::Method,
827 NodeKind::Variable,
828 NodeKind::External,
829 NodeKind::Doc,
830];
831
832const ALL_EDGE_KINDS: [EdgeKind; 9] = [
834 EdgeKind::Imports,
835 EdgeKind::Calls,
836 EdgeKind::Inherits,
837 EdgeKind::Implements,
838 EdgeKind::Defines,
839 EdgeKind::References,
840 EdgeKind::Contains,
841 EdgeKind::Reads,
842 EdgeKind::Writes,
843];
844
845fn node_kind_name(kind: NodeKind) -> &'static str {
851 match kind {
852 NodeKind::Package => "package",
853 NodeKind::Module => "module",
854 NodeKind::File => "file",
855 NodeKind::Class => "class",
856 NodeKind::Interface => "interface",
857 NodeKind::Function => "function",
858 NodeKind::Method => "method",
859 NodeKind::Variable => "variable",
860 NodeKind::External => "external",
861 NodeKind::Doc => "doc",
862 }
863}
864
865#[derive(Debug, Serialize)]
874struct VerifyResponse {
875 index_id: String,
876 entries: usize,
877 chain_valid: bool,
878 head: String,
879}
880
881async fn verify(
882 State(state): State<AppState>,
883 Extension(key): Extension<KeyId>,
884 UrlPath(index_id): UrlPath<String>,
885) -> Result<Json<VerifyResponse>, ApiError> {
886 enforce_tenancy(&state, &index_id, &key)?;
887 let db = state.db_path(&index_id);
888 require_index(&db, &index_id)?;
889
890 let (entries, chain_valid, head) = spawn_blocking(move || {
891 let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
892 let audit = store
893 .load_audit()
894 .map_err(|err| ApiError::internal(err.to_string()))?;
895 Ok::<_, ApiError>((audit.len(), audit.verify(), audit.head().to_string()))
896 })
897 .await?;
898
899 Ok(Json(VerifyResponse {
900 index_id,
901 entries,
902 chain_valid,
903 head,
904 }))
905}
906
907async fn snapshot(
919 State(state): State<AppState>,
920 Extension(key): Extension<KeyId>,
921 UrlPath(index_id): UrlPath<String>,
922) -> Result<Json<Snapshot>, ApiError> {
923 enforce_tenancy(&state, &index_id, &key)?;
924 let db = state.db_path(&index_id);
925 require_index(&db, &index_id)?;
926
927 spawn_blocking(move || {
928 let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
929 let graph = store
930 .load_graph()
931 .map_err(|err| ApiError::internal(err.to_string()))?;
932 let audit = store
933 .load_audit()
934 .map_err(|err| ApiError::internal(err.to_string()))?;
935 Ok::<_, ApiError>(Snapshot::from_graph(&graph, &audit.snapshot_entries()))
936 })
937 .await
938 .map(Json)
939}
940
941#[derive(Debug, Default, Deserialize)]
952struct NeighborhoodQuery {
953 node: Option<String>,
954 depth: Option<String>,
955}
956
957async fn neighborhood(
961 State(state): State<AppState>,
962 Extension(key): Extension<KeyId>,
963 UrlPath(index_id): UrlPath<String>,
964 Query(query): Query<NeighborhoodQuery>,
965) -> Result<Json<Value>, ApiError> {
966 enforce_tenancy(&state, &index_id, &key)?;
967 let node = query
968 .node
969 .as_deref()
970 .map(str::trim)
971 .filter(|raw| !raw.is_empty())
972 .ok_or_else(|| ApiError::missing_param("node"))?
973 .to_string();
974 let depth: u32 = match query.depth.as_deref().map(str::trim) {
975 None | Some("") => 1,
976 Some(raw) => raw.parse().map_err(|_| {
977 ApiError::invalid_param("depth", &format!("expected a non-negative integer, got '{raw}'"))
978 })?,
979 };
980 let db = state.db_path(&index_id);
981 let graph = load_graph_blocking(&db, &index_id)?;
982 require_node(&graph, &node).map_err(|e| map_cli_error(&index_id, e))?;
983 let sub = graph.neighborhood(&node, depth);
984 Ok(Json(json!({
985 "index_id": index_id,
986 "node": node,
987 "depth": depth,
988 "nodes": sub.nodes,
989 "edges": sub.edges,
990 })))
991}
992
993async fn symbol(
998 State(state): State<AppState>,
999 Extension(key): Extension<KeyId>,
1000 UrlPath(index_id): UrlPath<String>,
1001 Query(query): Query<ClosureQuery>,
1002) -> Result<Json<Value>, ApiError> {
1003 enforce_tenancy(&state, &index_id, &key)?;
1004 let node_id = query
1005 .node
1006 .as_deref()
1007 .map(str::trim)
1008 .filter(|raw| !raw.is_empty())
1009 .ok_or_else(|| ApiError::missing_param("node"))?
1010 .to_string();
1011 let db = state.db_path(&index_id);
1012 let graph = load_graph_blocking(&db, &index_id)?;
1013 let dossier = graph
1014 .dossier(&node_id)
1015 .ok_or_else(|| ApiError::unknown_node(&node_id))?;
1016 let mut value = serde_json::to_value(&dossier)
1017 .map_err(|err| ApiError::internal(format!("dossier does not serialize: {err}")))?;
1018 value["index_id"] = json!(index_id);
1019 Ok(Json(value))
1020}
1021
1022async fn diagnostics(
1027 State(state): State<AppState>,
1028 Extension(key): Extension<KeyId>,
1029 UrlPath(index_id): UrlPath<String>,
1030) -> Result<Json<Value>, ApiError> {
1031 enforce_tenancy(&state, &index_id, &key)?;
1032 let db = state.db_path(&index_id);
1033 require_index(&db, &index_id)?;
1034 let walk_index = index_id.clone();
1038 let mut value = spawn_blocking(move || {
1039 codetopo_cli::diagnostics_value(&db).map_err(|err| map_cli_error(&walk_index, err))
1040 })
1041 .await?;
1042 value["index_id"] = json!(index_id);
1043 Ok(Json(value))
1044}
1045
1046#[derive(Debug, Default, Deserialize)]
1050struct RankQuery {
1051 by: Option<String>,
1052 limit: Option<String>,
1053}
1054
1055async fn rank(
1063 State(state): State<AppState>,
1064 Extension(key): Extension<KeyId>,
1065 UrlPath(index_id): UrlPath<String>,
1066 Query(query): Query<RankQuery>,
1067) -> Result<Json<Value>, ApiError> {
1068 enforce_tenancy(&state, &index_id, &key)?;
1069 let by = query
1070 .by
1071 .as_deref()
1072 .map(str::trim)
1073 .filter(|raw| !raw.is_empty())
1074 .unwrap_or("blast_radius");
1075 let metric = codetopo_core::RankMetric::parse(by).ok_or_else(|| {
1076 ApiError::invalid_param(
1077 "by",
1078 &format!("expected fan_in, fan_out, or blast_radius, got '{by}'"),
1079 )
1080 })?;
1081 let limit: usize = match query.limit.as_deref().map(str::trim) {
1082 None | Some("") => DEFAULT_LIMIT,
1083 Some(raw) => {
1084 let parsed: usize = raw.parse().map_err(|_| {
1085 ApiError::invalid_param("limit", &format!("expected an integer, got '{raw}'"))
1086 })?;
1087 if parsed == 0 || parsed > MAX_LIMIT {
1088 return Err(ApiError::invalid_param(
1089 "limit",
1090 &format!("must be 1..={MAX_LIMIT}, got {parsed}"),
1091 ));
1092 }
1093 parsed
1094 }
1095 };
1096 let db = state.db_path(&index_id);
1097 let graph = load_graph_blocking(&db, &index_id)?;
1098 let mut value = codetopo_cli::rank_value(&graph, metric, limit);
1099 value["index_id"] = json!(index_id);
1100 Ok(Json(value))
1101}
1102
1103#[derive(Debug, Default, Deserialize)]
1105struct DiffQuery {
1106 other: Option<String>,
1107}
1108
1109async fn diff(
1117 State(state): State<AppState>,
1118 Extension(key): Extension<KeyId>,
1119 UrlPath(index_id): UrlPath<String>,
1120 Query(query): Query<DiffQuery>,
1121) -> Result<Json<Value>, ApiError> {
1122 enforce_tenancy(&state, &index_id, &key)?;
1123 let other_id = query
1124 .other
1125 .as_deref()
1126 .map(str::trim)
1127 .filter(|raw| !raw.is_empty())
1128 .ok_or_else(|| ApiError::missing_param("other"))?
1129 .to_string();
1130 enforce_tenancy(&state, &other_id, &key)?;
1131 let before = load_graph_blocking(&state.db_path(&index_id), &index_id)?;
1132 let after = load_graph_blocking(&state.db_path(&other_id), &other_id)?;
1133 let mut value = codetopo_cli::diff_value(&before, &after);
1134 value["index_id"] = json!(index_id);
1135 value["other_index_id"] = json!(other_id);
1136 Ok(Json(value))
1137}
1138
1139#[derive(Debug, Default, Deserialize)]
1141struct SearchQuery {
1142 q: Option<String>,
1143 limit: Option<String>,
1144}
1145
1146async fn search(
1149 State(state): State<AppState>,
1150 Extension(key): Extension<KeyId>,
1151 UrlPath(index_id): UrlPath<String>,
1152 Query(query): Query<SearchQuery>,
1153) -> Result<Json<Value>, ApiError> {
1154 enforce_tenancy(&state, &index_id, &key)?;
1155 let q = query
1156 .q
1157 .as_deref()
1158 .map(str::trim)
1159 .filter(|raw| !raw.is_empty())
1160 .ok_or_else(|| ApiError::missing_param("q"))?
1161 .to_string();
1162 let limit: usize = match query.limit.as_deref().map(str::trim) {
1163 None | Some("") => 20,
1164 Some(raw) => raw.parse().map_err(|_| {
1165 ApiError::invalid_param("limit", &format!("expected a positive integer, got '{raw}'"))
1166 })?,
1167 };
1168 let db = state.db_path(&index_id);
1169 let graph = load_graph_blocking(&db, &index_id)?;
1170 let hits = graph.search_symbols(&q, limit);
1171 Ok(Json(json!({
1172 "index_id": index_id,
1173 "query": q,
1174 "total": hits.len(),
1175 "results": hits,
1176 })))
1177}
1178
1179async fn schema() -> Json<Value> {
1183 Json(describe_schema())
1184}
1185
1186fn require_index(db: &FsPath, index_id: &str) -> Result<(), ApiError> {
1188 if !db.is_file() {
1189 return Err(ApiError::unknown_index(index_id));
1190 }
1191 Ok(())
1192}
1193
1194fn load_graph_blocking(db: &FsPath, index_id: &str) -> Result<Graph, ApiError> {
1196 require_index(db, index_id)?;
1197 load_graph_from_db(db).map_err(|err| map_cli_error(index_id, err))
1198}
1199
1200fn map_cli_error(index_id: &str, err: CliError) -> ApiError {
1207 match err {
1208 CliError::UnknownId(id) => ApiError::unknown_node(&id),
1209 other => ApiError::internal(format!("index {index_id}: {other}")),
1210 }
1211}
1212
1213async fn spawn_blocking<T, F>(work: F) -> Result<T, ApiError>
1219where
1220 F: FnOnce() -> Result<T, ApiError> + Send + 'static,
1221 T: Send + 'static,
1222{
1223 tokio::task::spawn_blocking(work)
1224 .await
1225 .map_err(|err| ApiError::internal(format!("worker task failed: {err}")))?
1226}