use std::collections::BTreeMap;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use axum::extract::{Extension, Path as UrlPath, Query, Request, State};
use axum::http::header::CONTENT_TYPE;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use codetopo_cli::{
default_package_name, describe_schema, index_repo, load_graph_from_db, render_path,
require_node, CliError,
};
use codetopo_core::graph::{Graph, PathStep};
use codetopo_core::schema::{EdgeKind, NodeKind};
use codetopo_core::snapshot::Snapshot;
use codetopo_store::Store;
use crate::auth::{KeyId, KeyPlan};
use crate::config::Config;
use crate::error::ApiError;
use crate::ingest;
use crate::keys::now_unix_secs;
use crate::limits::{plan_limits, Envelope};
use crate::tenancy::Tenancy;
const DEFAULT_LIMIT: usize = 20;
const MAX_LIMIT: usize = 1000;
#[derive(Debug, Clone)]
pub struct AppState {
config: Arc<Config>,
keys: Arc<crate::keys::KeyStore>,
tenancy: Arc<Tenancy>,
envelope: Arc<Envelope>,
}
impl AppState {
pub fn new(
config: Arc<Config>,
keys: Arc<crate::keys::KeyStore>,
tenancy: Arc<Tenancy>,
) -> Self {
AppState {
config,
keys,
tenancy,
envelope: Arc::new(Envelope::new()),
}
}
fn db_path(&self, index_id: &str) -> PathBuf {
self.config.index_db_path(index_id)
}
}
fn enforce_tenancy(state: &AppState, index_id: &str, key: &KeyId) -> Result<(), ApiError> {
let Some(record) = state.tenancy.lookup(index_id)? else {
if crate::tenancy::ownerless_readable(
state.config.deny_ownerless_indexes,
state.keys.active_len(),
) {
return Ok(());
}
return Err(ApiError::unknown_index(index_id));
};
if record.is_expired(now_unix_secs()) {
state.tenancy.remove(index_id)?;
state.config.remove_index_files(index_id);
return Err(ApiError::unknown_index(index_id));
}
if record.owner_key_id != key.as_str() {
return Err(ApiError::unknown_index(index_id));
}
Ok(())
}
pub fn router(state: AppState) -> Router {
let envelope = state.envelope.clone();
let queries = Router::new()
.route("/v1/graphs/:id/descendants", get(descendants))
.route("/v1/graphs/:id/ancestors", get(ancestors))
.route("/v1/graphs/:id/blast-radius", get(blast_radius))
.route("/v1/graphs/:id/path", get(path))
.route("/v1/graphs/:id/stats", get(stats))
.route("/v1/graphs/:id/verify", get(verify))
.route("/v1/graphs/:id/snapshot", get(snapshot))
.route("/v1/graphs/:id/neighborhood", get(neighborhood))
.route("/v1/graphs/:id/symbol", get(symbol))
.route("/v1/graphs/:id/search", get(search))
.route("/v1/graphs/:id/diagnostics", get(diagnostics))
.route("/v1/graphs/:id/rank", get(rank))
.route("/v1/graphs/:id/diff", get(diff))
.route("/v1/graphs/:id/metrics", get(metrics))
.route("/v1/graphs/:id/history", get(history))
.route("/v1/schema", get(schema))
.route_layer(axum::middleware::from_fn_with_state(
envelope,
crate::limits::query_envelope,
));
Router::new()
.route("/v1/index", post(index))
.merge(queries)
.with_state(state)
}
#[derive(Debug, Default, Deserialize)]
struct IndexRequest {
repo_path: Option<String>,
repo_url: Option<String>,
package: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct IndexQuery {
package: Option<String>,
}
const JSON_BODY_LIMIT: usize = 2 * 1024 * 1024;
fn non_empty(field: Option<String>) -> Option<String> {
field
.map(|raw| raw.trim().to_string())
.filter(|raw| !raw.is_empty())
}
async fn read_body(request: Request, limit: usize, failure: &str) -> Result<axum::body::Bytes, ApiError> {
axum::body::to_bytes(request.into_body(), limit)
.await
.map_err(|_| ApiError::invalid_body(failure))
}
async fn index(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
Extension(plan): Extension<KeyPlan>,
Query(query): Query<IndexQuery>,
request: Request,
) -> Result<Json<Value>, ApiError> {
let content_type = request
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| {
value
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
})
.filter(|value| !value.is_empty());
let index_id = uuid::Uuid::new_v4().to_string();
let db = state.db_path(&index_id);
let limits = plan_limits(plan.as_str());
let _index_permit = match limits.max_concurrent_index_ops {
Some(max_ops) => match state.envelope.try_acquire_index_op(key.as_str(), max_ops) {
Some(permit) => Some(permit),
None => return Err(ApiError::index_concurrency(plan.as_str(), max_ops)),
},
None => None,
};
if let Some(max_active) = limits.max_active_indexes {
let current = state
.tenancy
.count_active_for_owner(key.as_str(), now_unix_secs())?;
if current >= max_active {
return Err(ApiError::index_quota(plan.as_str(), max_active, current));
}
}
let caps = limits.ingest;
let (repo, package, workdir) = if content_type.as_deref() == Some("application/json") {
let bytes = read_body(
request,
JSON_BODY_LIMIT,
"request body could not be read (2 MiB limit)",
)
.await?;
let body: IndexRequest = serde_json::from_slice(&bytes).map_err(|err| {
ApiError::invalid_body(&format!("not valid JSON for this route: {err}"))
})?;
match (non_empty(body.repo_path), non_empty(body.repo_url)) {
(Some(_), Some(_)) => {
return Err(ApiError::invalid_body(
"repo_path and repo_url are alternatives; send exactly one",
));
}
(None, None) => {
return Err(ApiError::invalid_repo_path(
"one of repo_path or repo_url is required",
));
}
(Some(raw_path), None) => {
if !plan.is_self_hosted() {
return Err(ApiError::invalid_repo_path(
"repo_path names a path on the server host and is available \
only to self-hosted keys; use repo_url or an archive upload",
));
}
let repo = PathBuf::from(raw_path);
if !repo.is_dir() {
return Err(ApiError::invalid_repo_path(&format!(
"not a directory: {}",
repo.display()
)));
}
let package =
non_empty(body.package).unwrap_or_else(|| default_package_name(&repo));
(repo, package, None)
}
(None, Some(raw_url)) => {
let validated = ingest::validate_repo_url(&raw_url).await?;
let workdir = state.config.index_workdir_path(&index_id);
let clone_dir = workdir.clone();
let clone_validated = validated.clone();
tokio::task::spawn_blocking(move || {
ingest::clone_into(&clone_validated, &clone_dir, caps)
})
.await
.map_err(|err| ApiError::internal(format!("clone task failed: {err}")))??;
let package =
non_empty(body.package).unwrap_or_else(|| validated.package_name());
(workdir.clone(), package, Some(workdir))
}
}
} else {
let bytes = read_body(
request,
caps.max_upload_bytes,
"archive body could not be read or exceeds the upload limit",
)
.await?;
let kind = ingest::archive_kind(content_type.as_deref(), &bytes).ok_or_else(|| {
ApiError::invalid_body(
"unrecognized archive body: send a .zip or .tar.gz archive",
)
})?;
let workdir = state.config.index_workdir_path(&index_id);
let extract_dir = workdir.clone();
let root = tokio::task::spawn_blocking(move || {
ingest::extract_into(kind, &bytes, &extract_dir, caps)
})
.await
.map_err(|err| ApiError::internal(format!("extract task failed: {err}")))??;
let package = non_empty(query.package).unwrap_or_else(|| {
if root != workdir {
default_package_name(&root)
} else {
"repo".to_string()
}
});
(root, package, Some(workdir))
};
if let Some(max_files) = limits.max_files_per_index {
let count_root = repo.clone();
let counted = tokio::task::spawn_blocking(move || {
ingest::count_tree_files(&count_root, max_files as u64 + 1)
})
.await
.map_err(|err| ApiError::internal(format!("file count task failed: {err}")))?;
if counted > max_files as u64 {
if let Some(workdir) = &workdir {
let _ = std::fs::remove_dir_all(workdir);
}
return Err(ApiError::file_quota(plan.as_str(), max_files));
}
}
let report = tokio::task::spawn_blocking(move || index_repo(&repo, &db, &package, None, None))
.await
.map_err(|err| ApiError::internal(format!("index task failed: {err}")))?;
let report = match report {
Ok(report) => report,
Err(err) => {
let _ = std::fs::remove_file(state.db_path(&index_id));
if let Some(workdir) = &workdir {
let _ = std::fs::remove_dir_all(workdir);
}
return Err(ApiError::internal(format!("cannot index repository: {err}")));
}
};
let created_at = now_unix_secs();
let expires_at = state
.config
.index_ttl_secs
.map(|ttl| created_at + ttl as i64);
if let Err(err) = state
.tenancy
.record(&index_id, key.as_str(), created_at, expires_at)
{
let _ = std::fs::remove_file(state.db_path(&index_id));
if let Some(workdir) = &workdir {
let _ = std::fs::remove_dir_all(workdir);
}
return Err(err);
}
Ok(Json(json!({
"index_id": index_id,
"files_indexed": report.files_indexed,
"files_failed": report.files_failed,
"nodes": report.nodes,
"edges": report.edges,
"diagnostics": report.diagnostics,
})))
}
#[derive(Debug, Default, Deserialize)]
struct ClosureQuery {
node: Option<String>,
expand: Option<String>,
limit: Option<String>,
}
#[derive(Debug)]
struct ClosureParams {
node: String,
expand: bool,
limit: usize,
}
#[derive(Debug, Clone, Copy)]
enum Direction {
Downstream,
Upstream,
}
fn closure_params(query: &ClosureQuery) -> Result<ClosureParams, ApiError> {
let node = query
.node
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.ok_or_else(|| ApiError::missing_param("node"))?
.to_string();
let expand = match query.expand.as_deref().map(str::trim) {
None | Some("") => false,
Some("true") => true,
Some("false") => false,
Some(other) => {
return Err(ApiError::invalid_param(
"expand",
&format!("expected 'true' or 'false', got '{other}'"),
))
}
};
let limit = match query.limit.as_deref().map(str::trim) {
None | Some("") => DEFAULT_LIMIT,
Some(raw) => {
let parsed: usize = raw.parse().map_err(|_| {
ApiError::invalid_param("limit", &format!("expected an integer, got '{raw}'"))
})?;
if parsed == 0 || parsed > MAX_LIMIT {
return Err(ApiError::invalid_param(
"limit",
&format!("must be 1..={MAX_LIMIT}, got {parsed}"),
));
}
parsed
}
};
Ok(ClosureParams {
node,
expand,
limit,
})
}
#[derive(Debug, Serialize)]
struct ClosureResponse {
index_id: String,
relation: &'static str,
node: String,
total: usize,
expand: bool,
limit: usize,
truncated: bool,
nodes: Vec<String>,
}
async fn descendants(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<ClosureQuery>,
) -> Result<Json<ClosureResponse>, ApiError> {
closure(state, &key, index_id, query, "descendants", Direction::Downstream).await
}
async fn ancestors(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<ClosureQuery>,
) -> Result<Json<ClosureResponse>, ApiError> {
closure(state, &key, index_id, query, "ancestors", Direction::Upstream).await
}
async fn blast_radius(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<ClosureQuery>,
) -> Result<Json<ClosureResponse>, ApiError> {
closure(
state,
&key,
index_id,
query,
"blast_radius",
Direction::Downstream,
)
.await
}
async fn closure(
state: AppState,
key: &KeyId,
index_id: String,
query: ClosureQuery,
relation: &'static str,
direction: Direction,
) -> Result<Json<ClosureResponse>, ApiError> {
enforce_tenancy(&state, &index_id, key)?;
let ClosureParams {
node,
expand,
limit,
} = closure_params(&query)?;
let db = state.db_path(&index_id);
let graph = load_graph_blocking(&db, &index_id)?;
let walk_node = node.clone();
let ids = spawn_blocking(move || {
Ok(match direction {
Direction::Downstream if relation == "blast_radius" => graph.blast_radius(&walk_node),
Direction::Downstream => graph.descendants(&walk_node),
Direction::Upstream => graph.ancestors(&walk_node),
})
})
.await?;
let total = ids.len();
let truncated = !expand && total > limit;
let shown = if expand {
ids
} else {
ids[..total.min(limit)].to_vec()
};
Ok(Json(ClosureResponse {
index_id,
relation,
node,
total,
expand,
limit,
truncated,
nodes: shown,
}))
}
#[derive(Debug, Default, Deserialize)]
struct PathQuery {
from: Option<String>,
to: Option<String>,
}
#[derive(Debug, Serialize)]
struct PathResponse {
index_id: String,
from: String,
to: String,
steps: Vec<PathStep>,
rendered: String,
}
async fn path(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<PathQuery>,
) -> Result<Json<PathResponse>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let from = required_param(query.from.as_deref(), "from")?;
let to = required_param(query.to.as_deref(), "to")?;
let db = state.db_path(&index_id);
let graph = load_graph_blocking(&db, &index_id)?;
let steps = graph
.path(&from, &to)
.ok_or_else(|| ApiError::no_path(&from, &to))?;
let rendered = render_path(&steps);
Ok(Json(PathResponse {
index_id,
from,
to,
steps,
rendered,
}))
}
fn required_param(raw: Option<&str>, name: &str) -> Result<String, ApiError> {
raw.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| ApiError::missing_param(name))
}
#[derive(Debug, Serialize)]
struct StatsResponse {
index_id: String,
nodes: KindCounts,
edges: KindCounts,
}
#[derive(Debug, Serialize)]
struct KindCounts {
total: usize,
by_kind: BTreeMap<String, usize>,
}
async fn stats(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
) -> Result<Json<StatsResponse>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let db = state.db_path(&index_id);
let graph = load_graph_blocking(&db, &index_id)?;
let mut node_kinds = BTreeMap::new();
for kind in ALL_NODE_KINDS {
let count = graph.nodes().filter(|node| node.kind == kind).count();
if count > 0 {
node_kinds.insert(node_kind_name(kind).to_string(), count);
}
}
let mut edge_kinds = BTreeMap::new();
for kind in ALL_EDGE_KINDS {
let count = graph.edge_count_by_kind(kind);
if count > 0 {
edge_kinds.insert(kind.as_str().to_string(), count);
}
}
Ok(Json(StatsResponse {
index_id,
nodes: KindCounts {
total: graph.node_count(),
by_kind: node_kinds,
},
edges: KindCounts {
total: graph.edge_count(),
by_kind: edge_kinds,
},
}))
}
const ALL_NODE_KINDS: [NodeKind; 10] = [
NodeKind::Package,
NodeKind::Module,
NodeKind::File,
NodeKind::Class,
NodeKind::Interface,
NodeKind::Function,
NodeKind::Method,
NodeKind::Variable,
NodeKind::External,
NodeKind::Doc,
];
const ALL_EDGE_KINDS: [EdgeKind; 9] = [
EdgeKind::Imports,
EdgeKind::Calls,
EdgeKind::Inherits,
EdgeKind::Implements,
EdgeKind::Defines,
EdgeKind::References,
EdgeKind::Contains,
EdgeKind::Reads,
EdgeKind::Writes,
];
fn node_kind_name(kind: NodeKind) -> &'static str {
match kind {
NodeKind::Package => "package",
NodeKind::Module => "module",
NodeKind::File => "file",
NodeKind::Class => "class",
NodeKind::Interface => "interface",
NodeKind::Function => "function",
NodeKind::Method => "method",
NodeKind::Variable => "variable",
NodeKind::External => "external",
NodeKind::Doc => "doc",
}
}
#[derive(Debug, Serialize)]
struct VerifyResponse {
index_id: String,
entries: usize,
chain_valid: bool,
head: String,
}
async fn verify(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
) -> Result<Json<VerifyResponse>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let db = state.db_path(&index_id);
require_index(&db, &index_id)?;
let (entries, chain_valid, head) = spawn_blocking(move || {
let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
let audit = store
.load_audit()
.map_err(|err| ApiError::internal(err.to_string()))?;
Ok::<_, ApiError>((audit.len(), audit.verify(), audit.head().to_string()))
})
.await?;
Ok(Json(VerifyResponse {
index_id,
entries,
chain_valid,
head,
}))
}
async fn snapshot(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
) -> Result<Json<Snapshot>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let db = state.db_path(&index_id);
require_index(&db, &index_id)?;
spawn_blocking(move || {
let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
let graph = store
.load_graph()
.map_err(|err| ApiError::internal(err.to_string()))?;
let audit = store
.load_audit()
.map_err(|err| ApiError::internal(err.to_string()))?;
Ok::<_, ApiError>(Snapshot::from_graph(&graph, &audit.snapshot_entries()))
})
.await
.map(Json)
}
#[derive(Debug, Default, Deserialize)]
struct NeighborhoodQuery {
node: Option<String>,
depth: Option<String>,
}
async fn neighborhood(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<NeighborhoodQuery>,
) -> Result<Json<Value>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let node = query
.node
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.ok_or_else(|| ApiError::missing_param("node"))?
.to_string();
let depth: u32 = match query.depth.as_deref().map(str::trim) {
None | Some("") => 1,
Some(raw) => raw.parse().map_err(|_| {
ApiError::invalid_param("depth", &format!("expected a non-negative integer, got '{raw}'"))
})?,
};
let db = state.db_path(&index_id);
let graph = load_graph_blocking(&db, &index_id)?;
require_node(&graph, &node).map_err(|e| map_cli_error(&index_id, e))?;
let sub = graph.neighborhood(&node, depth);
Ok(Json(json!({
"index_id": index_id,
"node": node,
"depth": depth,
"nodes": sub.nodes,
"edges": sub.edges,
})))
}
async fn symbol(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<ClosureQuery>,
) -> Result<Json<Value>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let node_id = query
.node
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.ok_or_else(|| ApiError::missing_param("node"))?
.to_string();
let db = state.db_path(&index_id);
let graph = load_graph_blocking(&db, &index_id)?;
let dossier = graph
.dossier(&node_id)
.ok_or_else(|| ApiError::unknown_node(&node_id))?;
let mut value = serde_json::to_value(&dossier)
.map_err(|err| ApiError::internal(format!("dossier does not serialize: {err}")))?;
value["index_id"] = json!(index_id);
Ok(Json(value))
}
async fn diagnostics(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let db = state.db_path(&index_id);
require_index(&db, &index_id)?;
let walk_index = index_id.clone();
let mut value = spawn_blocking(move || {
codetopo_cli::diagnostics_value(&db).map_err(|err| map_cli_error(&walk_index, err))
})
.await?;
value["index_id"] = json!(index_id);
Ok(Json(value))
}
#[derive(Debug, Default, Deserialize)]
struct RankQuery {
by: Option<String>,
limit: Option<String>,
}
async fn rank(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<RankQuery>,
) -> Result<Json<Value>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let by = query
.by
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.unwrap_or("blast_radius");
let metric = codetopo_core::RankMetric::parse(by).ok_or_else(|| {
ApiError::invalid_param(
"by",
&format!("expected fan_in, fan_out, blast_radius, or complexity, got '{by}'"),
)
})?;
let limit: usize = match query.limit.as_deref().map(str::trim) {
None | Some("") => DEFAULT_LIMIT,
Some(raw) => {
let parsed: usize = raw.parse().map_err(|_| {
ApiError::invalid_param("limit", &format!("expected an integer, got '{raw}'"))
})?;
if parsed == 0 || parsed > MAX_LIMIT {
return Err(ApiError::invalid_param(
"limit",
&format!("must be 1..={MAX_LIMIT}, got {parsed}"),
));
}
parsed
}
};
let db = state.db_path(&index_id);
let graph = load_graph_blocking(&db, &index_id)?;
let mut value = codetopo_cli::rank_value(&graph, metric, limit);
value["index_id"] = json!(index_id);
Ok(Json(value))
}
async fn metrics(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let db = state.db_path(&index_id);
let graph = load_graph_blocking(&db, &index_id)?;
let mut value = codetopo_cli::metrics_value(&graph);
value["index_id"] = json!(index_id);
Ok(Json(value))
}
const HOSTED_HISTORY_REASON: &str = "hosted indexes carry no repository history; \
history is measured on local surfaces only (the CLI's history command, \
or the MCP tool with a local working tree)";
async fn history(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let db = state.db_path(&index_id);
let graph = load_graph_blocking(&db, &index_id)?;
let mut value = codetopo_cli::history_value(&graph, None, 2, HOSTED_HISTORY_REASON);
value["index_id"] = json!(index_id);
Ok(Json(value))
}
#[derive(Debug, Default, Deserialize)]
struct DiffQuery {
other: Option<String>,
}
async fn diff(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<DiffQuery>,
) -> Result<Json<Value>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let other_id = query
.other
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.ok_or_else(|| ApiError::missing_param("other"))?
.to_string();
enforce_tenancy(&state, &other_id, &key)?;
let before = load_graph_blocking(&state.db_path(&index_id), &index_id)?;
let after = load_graph_blocking(&state.db_path(&other_id), &other_id)?;
let mut value = codetopo_cli::diff_value(&before, &after);
value["index_id"] = json!(index_id);
value["other_index_id"] = json!(other_id);
Ok(Json(value))
}
#[derive(Debug, Default, Deserialize)]
struct SearchQuery {
q: Option<String>,
limit: Option<String>,
}
async fn search(
State(state): State<AppState>,
Extension(key): Extension<KeyId>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<SearchQuery>,
) -> Result<Json<Value>, ApiError> {
enforce_tenancy(&state, &index_id, &key)?;
let q = query
.q
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.ok_or_else(|| ApiError::missing_param("q"))?
.to_string();
let limit: usize = match query.limit.as_deref().map(str::trim) {
None | Some("") => 20,
Some(raw) => raw.parse().map_err(|_| {
ApiError::invalid_param("limit", &format!("expected a positive integer, got '{raw}'"))
})?,
};
let db = state.db_path(&index_id);
let graph = load_graph_blocking(&db, &index_id)?;
let hits = graph.search_symbols(&q, limit);
Ok(Json(json!({
"index_id": index_id,
"query": q,
"total": hits.len(),
"results": hits,
})))
}
async fn schema() -> Json<Value> {
Json(describe_schema())
}
fn require_index(db: &FsPath, index_id: &str) -> Result<(), ApiError> {
if !db.is_file() {
return Err(ApiError::unknown_index(index_id));
}
Ok(())
}
fn load_graph_blocking(db: &FsPath, index_id: &str) -> Result<Graph, ApiError> {
require_index(db, index_id)?;
load_graph_from_db(db).map_err(|err| map_cli_error(index_id, err))
}
fn map_cli_error(index_id: &str, err: CliError) -> ApiError {
match err {
CliError::UnknownId(id) => ApiError::unknown_node(&id),
other => ApiError::internal(format!("index {index_id}: {other}")),
}
}
async fn spawn_blocking<T, F>(work: F) -> Result<T, ApiError>
where
F: FnOnce() -> Result<T, ApiError> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(work)
.await
.map_err(|err| ApiError::internal(format!("worker task failed: {err}")))?
}