use std::collections::BTreeMap;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use axum::extract::{Path as UrlPath, Query, State};
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::config::Config;
use crate::error::ApiError;
const DEFAULT_LIMIT: usize = 20;
const MAX_LIMIT: usize = 1000;
#[derive(Debug, Clone)]
pub struct AppState {
config: Arc<Config>,
}
impl AppState {
pub fn new(config: Arc<Config>) -> Self {
AppState { config }
}
fn db_path(&self, index_id: &str) -> PathBuf {
self.config.index_db_path(index_id)
}
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/v1/index", post(index))
.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/schema", get(schema))
.with_state(state)
}
#[derive(Debug, Default, Deserialize)]
struct IndexRequest {
repo_path: Option<String>,
package: Option<String>,
}
async fn index(
State(state): State<AppState>,
Json(request): Json<IndexRequest>,
) -> Result<Json<Value>, ApiError> {
let repo = request
.repo_path
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.ok_or_else(|| ApiError::invalid_repo_path("repo_path is required"))?;
let repo = PathBuf::from(repo);
if !repo.is_dir() {
return Err(ApiError::invalid_repo_path(&format!(
"not a directory: {}",
repo.display()
)));
}
let package = request
.package
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.map(str::to_string)
.unwrap_or_else(|| default_package_name(&repo));
let index_id = uuid::Uuid::new_v4().to_string();
let db = state.db_path(&index_id);
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}")))?
.map_err(|err| ApiError::internal(format!("cannot index repository: {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>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<ClosureQuery>,
) -> Result<Json<ClosureResponse>, ApiError> {
closure(state, index_id, query, "descendants", Direction::Downstream).await
}
async fn ancestors(
State(state): State<AppState>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<ClosureQuery>,
) -> Result<Json<ClosureResponse>, ApiError> {
closure(state, index_id, query, "ancestors", Direction::Upstream).await
}
async fn blast_radius(
State(state): State<AppState>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<ClosureQuery>,
) -> Result<Json<ClosureResponse>, ApiError> {
closure(
state,
index_id,
query,
"blast_radius",
Direction::Downstream,
)
.await
}
async fn closure(
state: AppState,
index_id: String,
query: ClosureQuery,
relation: &'static str,
direction: Direction,
) -> Result<Json<ClosureResponse>, ApiError> {
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>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<PathQuery>,
) -> Result<Json<PathResponse>, ApiError> {
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>,
UrlPath(index_id): UrlPath<String>,
) -> Result<Json<StatsResponse>, ApiError> {
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>,
UrlPath(index_id): UrlPath<String>,
) -> Result<Json<VerifyResponse>, ApiError> {
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>,
UrlPath(index_id): UrlPath<String>,
) -> Result<Json<Snapshot>, ApiError> {
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>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<NeighborhoodQuery>,
) -> Result<Json<Value>, 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 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>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<ClosureQuery>,
) -> Result<Json<Value>, ApiError> {
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 node = require_node(&graph, &node_id).map_err(|e| map_cli_error(&index_id, e))?;
Ok(Json(json!({"index_id": index_id, "node": node})))
}
#[derive(Debug, Default, Deserialize)]
struct SearchQuery {
q: Option<String>,
limit: Option<String>,
}
async fn search(
State(state): State<AppState>,
UrlPath(index_id): UrlPath<String>,
Query(query): Query<SearchQuery>,
) -> Result<Json<Value>, ApiError> {
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}")))?
}