Skip to main content

codetopo_server/
handlers.rs

1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar (https://nrupalakolkar.com) · Built with Muse by Meta (https://muse.ai/join) — join Muse, then redeem code 101EEK in Settings within 48 hours of joining and we both earn tokens.
4
5//! The request handlers of the HTTP API (Phase 2 design §3, extended by
6//! the analytics-depth program: the symbol dossier, the diagnostics read,
7//! the ranking, and the diff).
8//!
9//! This module owns no graph logic. Every route is a transport rename of a
10//! command the CLI already runs: the same `codetopo_core` query answers it, and
11//! the same `codetopo_store` read backs it. That is deliberate — §3.2 makes
12//! CLI/API parity an exit criterion, and shared code is the only way to make
13//! parity true by construction rather than by two implementations that happen
14//! to agree today.
15//!
16//! Three rules shape the code below:
17//!
18//! * **Aggregate first.** A closure response always carries `total`, the full
19//!   size of the answer, before any list. An agent learns the shape of the
20//!   result from one integer and decides whether it can afford the list; a
21//!   response that is only a list forces it to page before it knows the size.
22//! * **Blocking work leaves the async runtime.** `rusqlite` and tree walking
23//!   are synchronous, and §6 requires every db-touching handler to run inside
24//!   `spawn_blocking` so a slow query cannot stall the accept loop.
25//! * **Errors are flat and coded.** Every failure path returns [`ApiError`],
26//!   whose body is `{"error": "<code>", "message": "…"}` (see [`crate::error`]
27//!   for why the body is flat rather than nested).
28
29use 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
57/// Default `limit` for closure queries, matching the CLI's `--limit` default.
58const DEFAULT_LIMIT: usize = 20;
59
60/// Upper bound on `limit` (§3.1). A closure over a whole repository can hold
61/// hundreds of thousands of ids, and an unbounded `limit` would let one request
62/// allocate the whole graph's id set — the id list is the response, so the cap
63/// is the only thing between a query and an out-of-memory.
64const MAX_LIMIT: usize = 1000;
65
66/// Shared, immutable server state.
67///
68/// The resolved configuration plus the tenancy store: the metering log
69/// is owned by the auth layer, which is applied around the router
70/// rather than inside it, and the key store is shared with that layer.
71/// A handler still cannot reach a secret — [`KeyStore`] exposes none;
72/// the state holds it for exactly one number, the active-key count the
73/// ownerless-index rule turns on (see [`crate::tenancy`]). Tenancy is
74/// different — a handler *must* consult it before touching an index —
75/// and it carries no secrets, only ownership records. The [`Envelope`]
76/// is the abuse-envelope state of paid-path S3: the query routes'
77/// concurrency cap and per-key index-operation permits
78/// (see [`crate::limits`]).
79#[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    /// Wraps a resolved [`Config`] and the [`Tenancy`] store as axum state.
89    ///
90    /// Takes the `Arc`s the process already holds rather than values, so
91    /// the state and the configuration `main` logs at startup are provably
92    /// the same values and building a router in a test does not require
93    /// cloning either. The envelope is process state, not configuration,
94    /// so it is created here rather than passed in.
95    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    /// Database path for one index id (§6: `<data_dir>/<id>.db`).
109    fn db_path(&self, index_id: &str) -> PathBuf {
110        self.config.index_db_path(index_id)
111    }
112}
113
114/// The tenancy gate every `/v1/graphs/:id/…` handler passes before it
115/// touches the index (paid-path plan S1, gap G2).
116///
117/// * No ownership record: a legacy index (written before tenancy, or
118///   placed by another tool) — readable only as
119///   [`crate::tenancy::ownerless_readable`] rules: the operator's flag
120///   when set, else only while the key store holds a single active
121///   key. A denied legacy index is answered exactly like a missing
122///   one, so the refusal cannot confirm the index exists either.
123/// * Record owned by another key: answered exactly like a missing index
124///   (`404 unknown_index`), so the response cannot confirm the index
125///   exists. There is no `403` anywhere in this API's error vocabulary,
126///   and a distinguishable "exists but not yours" would leak it.
127/// * Expired record: refused the same way, and cleaned up on the spot —
128///   the record is dropped and the database file deleted — so expiry is
129///   enforced even between the startup sweeps in `Config::prepare`.
130fn 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        // The database and, for a hosted ingest, its staged tree:
143        // an expired index leaves nothing behind.
144        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
153/// Build the unauthenticated route table.
154///
155/// [`crate::auth::metered`] wraps the result; this function only decides which
156/// paths exist, so that auth coverage is a property of the wrapper rather than
157/// something each route has to opt into. The index handler also takes the
158/// [`KeyId`] and [`KeyPlan`] request extensions, which only that wrapper
159/// inserts — serving this router without it answers `500` on that route
160/// rather than skipping the tenancy and plan checks.
161pub fn router(state: AppState) -> Router {
162    // The query routes sit under the envelope middleware (global
163    // concurrency cap + the caller's plan timeout, paid-path S3);
164    // index creation does not — it has its own per-key permit and
165    // ingest bounds inside the handler, and a creation's blocking
166    // work must never be abandoned by a timer (see `index`).
167    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// ---------------------------------------------------------------------------
194// POST /v1/index
195// ---------------------------------------------------------------------------
196
197/// Body of `POST /v1/index` in JSON mode.
198///
199/// Every field is optional at the type level so that a *missing* field is
200/// reported in the API's own error vocabulary naming it, rather than as a
201/// generic deserialization rejection. The handler reads and parses the
202/// body itself instead of using an extractor, because the same route
203/// also serves the archive-upload mode, whose body is not JSON at all —
204/// the content type, not the route, picks the mode (see [`index`]).
205#[derive(Debug, Default, Deserialize)]
206struct IndexRequest {
207    repo_path: Option<String>,
208    repo_url: Option<String>,
209    package: Option<String>,
210}
211
212/// Query string of `POST /v1/index`. Only the archive mode reads it: an
213/// upload has no JSON body to carry a `package`, so the name rides the
214/// query string there. JSON mode takes `package` from the body.
215#[derive(Debug, Default, Deserialize)]
216struct IndexQuery {
217    package: Option<String>,
218}
219
220// Package name recorded for the index when the request omits one:
221// the shared default every surface uses
222// (`codetopo_cli::default_package_name` — the repository directory's
223// file stem). Before this was unified, the same fixture indexed as
224// `fixture-repo::…` via CLI/MCP but `unknown::…` here, so node ids —
225// and every query keyed on them — disagreed by surface.
226
227/// The largest JSON body `POST /v1/index` reads: the limit the JSON
228/// extractor enforced when it read the body, kept so taking over the
229/// read does not silently change what a client may send.
230const JSON_BODY_LIMIT: usize = 2 * 1024 * 1024;
231
232/// A field's value, trimmed, with empty treated as absent — the rule
233/// every optional field of this route follows.
234fn non_empty(field: Option<String>) -> Option<String> {
235    field
236        .map(|raw| raw.trim().to_string())
237        .filter(|raw| !raw.is_empty())
238}
239
240/// Reads a request body up to `limit`, mapping any failure (including
241/// passing the limit) to `invalid_body` with the caller's wording.
242async 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
248/// `POST /v1/index` — index a repository into a new index id.
249///
250/// Walks the tree, writes `<data_dir>/<index_id>.db`, and returns the CLI's own
251/// `IndexReport` fields, so API stats and CLI stats come from one computation.
252/// The index is recorded as owned by the creating key (see
253/// [`enforce_tenancy`]), with the configured TTL as its expiry if one is set.
254/// If the ownership record cannot be written, the fresh database is deleted
255/// again rather than left behind as an unowned index anyone could read.
256///
257/// ## Ingest modes (paid-path S2, gap G1)
258///
259/// The request names the tree in exactly one of three ways:
260///
261/// * **`repo_path`** (JSON) — a path on the server host, offered only
262///   to `self-hosted` keys. Naming host paths is the operator's own
263///   power; a hosted key asking for it is refused `invalid_repo_path`.
264/// * **`repo_url`** (JSON) — a public HTTPS git URL, validated and
265///   shallow-cloned into the index's workdir
266///   (`<data_dir>/ingest/<index_id>/`) by [`crate::ingest`]. Private
267///   repositories are out of v1 scope: no credentials are accepted in
268///   the URL, and the server holds none.
269/// * **Archive upload** — the request body itself is a `.zip` or
270///   `.tar.gz` (declared by content type, or sniffed from the magic
271///   bytes), extracted into the same workdir under the containment
272///   rules in [`crate::ingest`]. An explicit `package` rides the query
273///   string (`?package=`), there being no JSON body to carry it.
274///
275/// `repo_path` and `repo_url` in one body is `invalid_body`; neither is
276/// `invalid_repo_path`. An ingested tree belongs to its index: expiry
277/// deletes the workdir with the database, and a failed ingest or index
278/// removes the workdir before the error is answered — a partial tree is
279/// never left behind.
280///
281/// ## Limits (paid-path S3, gap G4)
282///
283/// The caller's plan ([`crate::limits`]) bounds this route four ways,
284/// all refused `429` before any ingest work is spent on them: one
285/// index operation at a time per key (the permit is held until the
286/// ownership record is written, so the next check is exact), an
287/// active-index quota counted from the tenancy store, the plan's
288/// ingest caps on the clone/upload itself, and a per-index file cap
289/// counted over the resolved tree before indexing starts — a tree
290/// past the cap leaves nothing behind, like any other failed ingest.
291async 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    // §6: a UUID v4 rather than a repo name, so re-indexing the same tree does
313    // not clobber the previous index and no customer directory layout leaks
314    // into a URL or a log line.
315    let index_id = uuid::Uuid::new_v4().to_string();
316    let db = state.db_path(&index_id);
317
318    // The plan envelope (S3): this key's limits, the per-key index
319    // permit, and its active-index quota — all before mode resolution,
320    // so a refused creation costs no clone, no upload read, no walk.
321    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    // Resolve the three modes to the tree to walk, the package name to
340    // record, and the workdir this request owns (hosted modes only —
341    // a local path is the caller's own tree and is never deleted here).
342    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    // The per-index file cap (S3): counted over the resolved tree
426    // before indexing starts, with the walk stopping just past the
427    // cap. Counting first is what makes the refusal clean — no
428    // extraction is paid for and no partial index exists to clean
429    // up; the workdir removal is the same discipline every failed
430    // ingest follows.
431    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            // A failed index leaves nothing behind: not the workdir,
453            // and not a partially written database — an unrecorded
454            // database file would read as a legacy index to any key.
455            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// ---------------------------------------------------------------------------
490// Closure routes: descendants / ancestors / blast-radius
491// ---------------------------------------------------------------------------
492
493/// Raw query string for a closure route.
494///
495/// `expand` and `limit` are read as strings so a bad value is reported as
496/// `invalid_param` naming the parameter, instead of being turned into a
497/// deserialization error before a handler sees it.
498#[derive(Debug, Default, Deserialize)]
499struct ClosureQuery {
500    node: Option<String>,
501    expand: Option<String>,
502    limit: Option<String>,
503}
504
505/// Validated closure parameters.
506#[derive(Debug)]
507struct ClosureParams {
508    node: String,
509    expand: bool,
510    limit: usize,
511}
512
513/// Which direction a closure walks.
514#[derive(Debug, Clone, Copy)]
515enum Direction {
516    /// Outgoing edges: what this node affects.
517    Downstream,
518    /// Incoming edges: what depends on this node.
519    Upstream,
520}
521
522/// Parse `node`, `expand`, and `limit` per §3.1.
523///
524/// Absent and empty are treated alike, so `?limit=` means "use the default"
525/// rather than "reject", which keeps a client that templates optional query
526/// parameters from failing on the empty string.
527fn 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/// Aggregate-first closure response.
572///
573/// `total` is the size of the *whole* closure; `nodes` is a possibly-truncated
574/// view of it. `truncated` says which of the two the client got, so a client
575/// never has to infer it by comparing two numbers.
576#[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
588/// `GET /v1/graphs/{id}/descendants?node=&expand=&limit=`.
589///
590/// What this node affects: the transitive closure of its outgoing call and
591/// import edges.
592async 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
601/// `GET /v1/graphs/{id}/ancestors?node=&expand=&limit=`.
602///
603/// The reverse closure: everything that transitively reaches this node.
604async 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
613/// `GET /v1/graphs/{id}/blast-radius?node=&expand=&limit=`.
614///
615/// Breakage propagation: the dependency-side closure a failure of this node
616/// would take with it.
617async 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
634/// Shared body of the three closure routes.
635///
636/// ## Deviation from §3.1
637///
638/// §3.1 says `expand=true` returns "the full id list" and, in the same table,
639/// that `limit` is the "max ids returned when `expand=true`". Those two
640/// statements cannot both hold, and the design gives no tie-breaker. CLI
641/// parity (§3.2) is the tie-breaker used here: `print_id_list` caps the list at
642/// `limit` by default and returns everything under `--expand`. So `limit` caps
643/// the default list and `expand=true` lifts the cap. Nothing is lost either way,
644/// because `total` is always the full size — which is the reason the aggregate
645/// comes first.
646async 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    // The walk owns the graph and a copy of the id: `spawn_blocking` needs a
664    // `'static` closure, so neither can be borrowed from this frame. The
665    // response still reports the caller's `node`, so the copy is the only
666    // duplicate of the id that crosses the boundary.
667    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// ---------------------------------------------------------------------------
698// GET /v1/graphs/{id}/path
699// ---------------------------------------------------------------------------
700
701/// Raw query string for `path`. Read as strings for the same reason as
702/// [`ClosureQuery`]: a missing `from` must be `missing_param`, not a serde
703/// error from the extractor.
704#[derive(Debug, Default, Deserialize)]
705struct PathQuery {
706    from: Option<String>,
707    to: Option<String>,
708}
709
710/// `GET /v1/graphs/{id}/path?from=&to=` — the shortest typed path between two
711/// nodes.
712///
713/// The response is deliberately the *whole* path rather than a capped prefix:
714/// a path is a sequence whose meaning depends on every step, so truncating it
715/// would produce a well-formed but false answer.
716#[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// ---------------------------------------------------------------------------
759// GET /v1/graphs/{id}/stats
760// ---------------------------------------------------------------------------
761
762/// Aggregate counts: totals plus per-kind breakdowns.
763///
764/// The breakdown omits zero kinds, matching `print_stats` (§3.2 parity), and
765/// `by_kind` is a map rather than an array so a client can read a single kind
766/// without walking a list to find it.
767#[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
818/// Every node kind, in schema declaration order.
819const 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
832/// Every edge kind, in schema declaration order.
833const 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
845/// Snake_case wire name of a node kind.
846///
847/// `NodeKind` has no `as_str` of its own (unlike `EdgeKind`), so the names
848/// live here; they are kept in step with the schema's `rename_all =
849/// "snake_case"` serialization, which is what clients see.
850fn 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// ---------------------------------------------------------------------------
866// GET /v1/graphs/{id}/verify
867// ---------------------------------------------------------------------------
868
869/// Audit-chain verification: entry count, chain validity, and head hash.
870///
871/// The same three facts `verify_db` prints. This is the route an agent calls
872/// before trusting a snapshot it was handed.
873#[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
907// ---------------------------------------------------------------------------
908// GET /v1/graphs/{id}/snapshot
909// ---------------------------------------------------------------------------
910
911/// `GET /v1/graphs/{id}/snapshot` — portable frozen export of the whole graph.
912///
913/// Serialized as the `Snapshot` struct itself rather than wrapped, so the
914/// response is byte-compatible with what `codetopo restore` accepts: a client
915/// can pipe this body straight to a file and restore it with no translation
916/// step. The snapshot binds `audit_head`, so a consumer can tell which audit
917/// chain it came from.
918async 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// ---------------------------------------------------------------------------
942// shared helpers
943// ---------------------------------------------------------------------------
944
945// ---------------------------------------------------------------------------
946// GET /v1/graphs/{id}/neighborhood, /symbol, /search, /diagnostics;
947// GET /v1/schema
948// ---------------------------------------------------------------------------
949
950/// Query for `neighborhood`: the shared closure params plus `depth`.
951#[derive(Debug, Default, Deserialize)]
952struct NeighborhoodQuery {
953    node: Option<String>,
954    depth: Option<String>,
955}
956
957/// `GET /v1/graphs/{id}/neighborhood?node=&depth=` — the subgraph
958/// within `depth` hops of a node, both directions (CODE_SCHEMA §6's
959/// neighborhood query; the CLI and MCP serve the same computation).
960async 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
993/// `GET /v1/graphs/{id}/symbol?node=` — one node in full: the DESIGN §3
994/// dossier (the node, its incident edges grouped by kind and direction,
995/// and the counts), serialized by the core type itself so the CLI and
996/// MCP answers are the same object plus `index_id`.
997async 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
1022/// `GET /v1/graphs/{id}/diagnostics` — the diagnostics recorded when the
1023/// index was built (cycles, multi-definitions, unresolved calls, …),
1024/// read from the store exactly as the CLI prints them: the shared
1025/// [`codetopo_cli::diagnostics_value`] shape plus `index_id`.
1026async 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    // The blocking closure is 'static, so it takes its own copy of the id
1035    // (the same pattern as the closure routes); the response still reports
1036    // the caller's `index_id`.
1037    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/// Query for `rank`: the metric name and the cap, read as strings so a
1047/// bad value is `invalid_param` naming the parameter (the closure
1048/// routes' discipline).
1049#[derive(Debug, Default, Deserialize)]
1050struct RankQuery {
1051    by: Option<String>,
1052    limit: Option<String>,
1053}
1054
1055/// `GET /v1/graphs/{id}/rank?by=&limit=` — the top symbols by a
1056/// measured count (fan-in, fan-out, or blast-radius size), with the
1057/// metric's published definition stated in the response. The shared
1058/// [`codetopo_cli::rank_value`] shape plus `index_id`: the CLI's
1059/// `rank` command and the MCP `codetopo_rank` tool return the same
1060/// object. Default metric is `blast_radius`; `limit` follows the
1061/// closure routes' rule (default 20, 1..=1000).
1062async 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/// Query for `diff`: the other index's id.
1104#[derive(Debug, Default, Deserialize)]
1105struct DiffQuery {
1106    other: Option<String>,
1107}
1108
1109/// `GET /v1/graphs/{id}/diff?other={other_id}` — the change-impact diff
1110/// of this index (before) against another index (after): added,
1111/// removed, and changed nodes and edges, plus the blast-radius
1112/// movement of every touched symbol. Tenancy is enforced on BOTH
1113/// indexes — naming another key's index as either side is answered
1114/// exactly like a missing one. The shared
1115/// [`codetopo_cli::diff_value`] shape plus the two index ids.
1116async 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/// Query for `search`.
1140#[derive(Debug, Default, Deserialize)]
1141struct SearchQuery {
1142    q: Option<String>,
1143    limit: Option<String>,
1144}
1145
1146/// `GET /v1/graphs/{id}/search?q=&limit=` — name discovery: the step
1147/// the exact-id queries always assumed a caller had already done.
1148async 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
1179/// `GET /v1/schema` — the schema description, identical to the CLI's
1180/// `schema` command and the MCP `codetopo_describe_schema` tool
1181/// (one generator in the CLI library serves all three).
1182async fn schema() -> Json<Value> {
1183    Json(describe_schema())
1184}
1185
1186/// Open an index database, mapping "no such file" to `unknown_index`.
1187fn 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
1194/// Load a graph off the runtime, mapping store and id failures to API errors.
1195fn 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
1200/// Translate a CLI-layer error into the API's error vocabulary.
1201///
1202/// Only `UnknownId` is a client mistake: the queried node is not in the graph,
1203/// which is the same `404 unknown_node` the CLI signals with exit code 2.
1204/// Everything else is the server's problem — a store or io failure behind a
1205/// request that was otherwise well-formed.
1206fn 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
1213/// Run synchronous work on the blocking pool and propagate a join failure.
1214///
1215/// A `JoinError` means the worker panicked or was cancelled; there is no
1216/// request left to answer, so it is reported as `internal` rather than
1217/// silently swallowed.
1218async 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}