velesdb-server 1.13.6

REST API server for VelesDB vector database
Documentation
//! Graph HTTP handlers for VelesDB REST API.
//!
//! All graph operations are routed through `AppState.db.get_graph_collection()`.
//! No separate GraphService state — graph data persists via GraphCollection/GraphEngine.
//!
//! Extended handlers (parity endpoints) live in [`super::handlers_extended`].

use std::sync::Arc;

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    Json,
};
use velesdb_core::collection::graph::{GraphEdge, TraversalConfig};

use crate::types::ErrorResponse;
use crate::AppState;

use super::types::{
    AddEdgeRequest, DegreeResponse, EdgeQueryParams, EdgeResponse, EdgesResponse, TraversalStats,
    TraverseRequest, TraverseResponse,
};

/// Shared graph preamble: record metric and resolve collection.
///
/// Mirrors [`super::super::search::search_preamble`] for graph handlers.
/// `GraphCollection` does not expose guard rails, so only the metrics
/// recording and collection resolution steps are performed.
#[allow(clippy::result_large_err)]
pub(super) fn graph_preamble(
    state: &AppState,
    name: &str,
) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
    state.onboarding_metrics.record_graph_request();
    get_graph_collection_or_404(state, name)
}

/// Resolves a `GraphCollection` by name.
///
/// # Returns
///
/// * `Ok(collection)` if a graph collection with this name exists.
/// * `Err(404 Not Found)` if no collection with this name exists.
/// * `Err(409 Conflict)` if a collection exists with this name but is not
///   a graph collection (type mismatch with vector or metadata collection).
///
/// # Contract
///
/// This function previously auto-created a schemaless graph collection
/// on first use. That behaviour is retired (F-05): a missing graph
/// collection now yields a 404 response instead of being created
/// silently. Callers must issue `POST /collections` with
/// `collection_type = "graph"` before targeting graph endpoints.
pub(super) fn get_graph_collection_or_404(
    state: &AppState,
    name: &str,
) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
    if let Some(c) = state.db.get_graph_collection(name) {
        return Ok(c);
    }

    // Check if a non-graph collection exists with this name (type mismatch → 409).
    if state.db.get_vector_collection(name).is_some()
        || state.db.get_metadata_collection(name).is_some()
    {
        return Err((
            StatusCode::CONFLICT,
            Json(ErrorResponse {
                error: format!(
                    "Collection '{name}' exists but is not a graph collection. \
                     Use /collections/{name}/graph only on graph-typed collections.",
                ),
                code: None,
            }),
        ));
    }

    // PR #586 Devin fix: propagate `VELES-002 CollectionNotFound` so
    // typed-error clients surface `CollectionNotFoundError` instead of
    // a status-derived `'NOT_FOUND'` string. The "create it first"
    // hint stays in the message for human operators.
    let err = velesdb_core::Error::CollectionNotFound(name.to_string());
    Err((
        StatusCode::NOT_FOUND,
        Json(ErrorResponse {
            error: format!(
                "{err}. Create it first with \
                 POST /collections and collection_type = \"graph\".",
            ),
            code: Some(err.code().to_string()),
        }),
    ))
}

/// Get edges from a collection's graph filtered by label.
#[utoipa::path(
    get,
    path = "/collections/{name}/graph/edges",
    params(("name" = String, Path, description = "Collection name"), EdgeQueryParams),
    responses(
        (status = 200, description = "Edges retrieved successfully", body = EdgesResponse),
        (status = 400, description = "Missing required 'label' query parameter", body = ErrorResponse),
        (status = 404, description = "Collection not found", body = ErrorResponse),
        (status = 500, description = "Internal server error", body = ErrorResponse)
    ),
    tag = "graph"
)]
pub async fn get_edges(
    Path(name): Path<String>,
    Query(params): Query<EdgeQueryParams>,
    State(state): State<Arc<AppState>>,
) -> Result<Json<EdgesResponse>, (StatusCode, Json<ErrorResponse>)> {
    let label = params.label.ok_or_else(|| {
        (
            StatusCode::BAD_REQUEST,
            Json(ErrorResponse {
                error: "Query parameter 'label' is required. Listing all edges requires pagination (not yet implemented).".to_string(),
                code: None,
            }),
        )
    })?;

    let coll = graph_preamble(&state, &name)?;

    let edges: Vec<EdgeResponse> = coll
        .get_edges(Some(&label))
        .into_iter()
        .map(|e| EdgeResponse {
            id: e.id(),
            source: e.source(),
            target: e.target(),
            label: e.label().to_string(),
            properties: serde_json::to_value(e.properties()).unwrap_or_default(),
        })
        .collect();

    let count = edges.len();
    Ok(Json(EdgesResponse { edges, count }))
}

/// Add an edge to a collection's graph.
#[utoipa::path(
    post,
    path = "/collections/{name}/graph/edges",
    request_body = AddEdgeRequest,
    responses(
        (status = 201, description = "Edge added successfully"),
        (status = 400, description = "Invalid request", body = ErrorResponse),
        (status = 404, description = "Collection not found", body = ErrorResponse),
        (status = 500, description = "Internal server error", body = ErrorResponse)
    ),
    tag = "graph"
)]
pub async fn add_edge(
    Path(name): Path<String>,
    State(state): State<Arc<AppState>>,
    Json(request): Json<AddEdgeRequest>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
    let properties: std::collections::HashMap<String, serde_json::Value> = match request.properties
    {
        serde_json::Value::Object(map) => map.into_iter().collect(),
        serde_json::Value::Null => std::collections::HashMap::new(),
        _ => {
            return Err((
                StatusCode::BAD_REQUEST,
                Json(ErrorResponse {
                    error: "Properties must be an object or null".to_string(),
                    code: None,
                }),
            ));
        }
    };

    let edge = GraphEdge::new(request.id, request.source, request.target, &request.label)
        .map_err(|e| {
            (
                StatusCode::BAD_REQUEST,
                Json(ErrorResponse {
                    error: format!("Invalid edge: {e}"),
                    code: None,
                }),
            )
        })?
        .with_properties(properties);

    let coll = graph_preamble(&state, &name)?;

    coll.add_edge(edge).map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ErrorResponse {
                error: format!("Failed to add edge: {e}"),
                code: None,
            }),
        )
    })?;

    Ok(StatusCode::CREATED)
}

/// Traverse the graph using BFS or DFS from a source node.
#[utoipa::path(
    post,
    path = "/collections/{name}/graph/traverse",
    request_body = TraverseRequest,
    responses(
        (status = 200, description = "Traversal completed successfully", body = TraverseResponse),
        (status = 400, description = "Invalid request", body = ErrorResponse),
        (status = 404, description = "Collection not found", body = ErrorResponse),
        (status = 500, description = "Internal server error", body = ErrorResponse)
    ),
    tag = "graph"
)]
pub async fn traverse_graph(
    Path(name): Path<String>,
    State(state): State<Arc<AppState>>,
    Json(request): Json<TraverseRequest>,
) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
    let coll = graph_preamble(&state, &name)?;

    let config = TraversalConfig::with_range(1, request.max_depth)
        .with_limit(request.limit)
        .with_rel_types(request.rel_types);

    let raw_results = match request.strategy.to_lowercase().as_str() {
        "bfs" => coll.traverse_bfs(request.source, &config),
        "dfs" => coll.traverse_dfs(request.source, &config),
        _ => {
            return Err((
                StatusCode::BAD_REQUEST,
                Json(ErrorResponse {
                    error: format!(
                        "Invalid strategy '{}'. Use 'bfs' or 'dfs'.",
                        request.strategy
                    ),
                    code: None,
                }),
            ));
        }
    };

    let results: Vec<super::types::TraversalResultItem> = raw_results
        .into_iter()
        .map(|r| super::types::TraversalResultItem {
            target_id: r.target_id,
            depth: r.depth,
            path: r.path,
        })
        .collect();

    let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
    let visited = results.len();
    let has_more = visited >= request.limit;

    Ok(Json(TraverseResponse {
        results,
        has_more,
        stats: TraversalStats {
            visited,
            depth_reached,
        },
    }))
}

/// Get the degree (in and out) of a specific node.
#[utoipa::path(
    get,
    path = "/collections/{name}/graph/nodes/{node_id}/degree",
    params(
        ("name" = String, Path, description = "Collection name"),
        ("node_id" = u64, Path, description = "Node ID")
    ),
    responses(
        (status = 200, description = "Degree retrieved successfully", body = DegreeResponse),
        (status = 404, description = "Collection not found", body = ErrorResponse),
        (status = 500, description = "Internal server error", body = ErrorResponse)
    ),
    tag = "graph"
)]
pub async fn get_node_degree(
    Path((name, node_id)): Path<(String, u64)>,
    State(state): State<Arc<AppState>>,
) -> Result<Json<DegreeResponse>, (StatusCode, Json<ErrorResponse>)> {
    let coll = graph_preamble(&state, &name)?;
    let (in_degree, out_degree) = coll.node_degree(node_id);
    Ok(Json(DegreeResponse {
        in_degree,
        out_degree,
    }))
}