Skip to main content

velesdb_server/handlers/graph/
handlers_extended.rs

1//! Extended graph HTTP handlers for VelesDB REST API.
2//!
3//! Handlers added for API parity: remove_edge, edge_count, list_nodes,
4//! node_edges, node_payload, parallel traversal, graph search.
5
6use std::sync::Arc;
7
8use axum::{
9    extract::{Path, Query, State},
10    http::StatusCode,
11    Json,
12};
13use velesdb_core::collection::graph::TraversalConfig;
14use velesdb_core::observer::QueryOperationKind;
15
16use crate::handlers::helpers::run_blocking_typed;
17use crate::types::ErrorResponse;
18use crate::AppState;
19
20use super::handlers::{graph_preamble, graph_read_preamble};
21use super::types::{
22    EdgeCountResponse, EdgeResponse, EdgesResponse, GraphSearchRequest, GraphSearchResponse,
23    GraphSearchResultItem, NodeEdgeQueryParams, NodeListResponse, NodePayloadResponse,
24    ParallelTraverseRequest, TraversalStats, TraverseResponse, UpsertNodePayloadRequest,
25};
26
27/// Remove an edge by ID.
28#[utoipa::path(
29    delete,
30    path = "/collections/{name}/graph/edges/{edge_id}",
31    params(
32        ("name" = String, Path, description = "Collection name"),
33        ("edge_id" = String, Path, description = "Edge ID to remove (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
34    ),
35    responses(
36        (status = 204, description = "Edge removed successfully"),
37        (status = 404, description = "Edge or collection not found", body = ErrorResponse),
38        (status = 500, description = "Internal server error", body = ErrorResponse)
39    ),
40    tag = "graph"
41)]
42pub async fn remove_edge(
43    Path((name, edge_id)): Path<(String, u64)>,
44    State(state): State<Arc<AppState>>,
45) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
46    let coll = graph_preamble(&state, &name)?;
47    // Edge removal takes write locks and persists — run it on the blocking pool.
48    if run_blocking_typed(move || coll.remove_edge(edge_id)).await? {
49        Ok(StatusCode::NO_CONTENT)
50    } else {
51        // PR #586 Devin fix: emit `VELES-020 EdgeNotFound` with the
52        // verbatim code so typed-error clients surface
53        // `EdgeNotFoundError` instead of falling back to a status-
54        // derived `'NOT_FOUND'` string. The error message retains the
55        // collection context for operators reading server logs.
56        let err = velesdb_core::Error::EdgeNotFound(edge_id);
57        Err((
58            StatusCode::NOT_FOUND,
59            Json(ErrorResponse {
60                error: format!("{err} in collection '{name}'"),
61                code: Some(err.code().to_string()),
62            }),
63        ))
64    }
65}
66
67/// Get the total number of edges in the graph.
68#[utoipa::path(
69    get,
70    path = "/collections/{name}/graph/edges/count",
71    params(
72        ("name" = String, Path, description = "Collection name")
73    ),
74    responses(
75        (status = 200, description = "Edge count retrieved", body = EdgeCountResponse),
76        (status = 404, description = "Collection not found", body = ErrorResponse)
77    ),
78    tag = "graph"
79)]
80pub async fn get_edge_count(
81    Path(name): Path<String>,
82    State(state): State<Arc<AppState>>,
83) -> Result<Json<EdgeCountResponse>, (StatusCode, Json<ErrorResponse>)> {
84    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
85    // Edge counting takes edge-store shard locks — run it on the blocking pool.
86    let count = run_blocking_typed(move || coll.edge_count()).await?;
87    Ok(Json(EdgeCountResponse { count }))
88}
89
90/// List all node IDs in the graph.
91#[utoipa::path(
92    get,
93    path = "/collections/{name}/graph/nodes",
94    params(
95        ("name" = String, Path, description = "Collection name")
96    ),
97    responses(
98        (status = 200, description = "Node list retrieved", body = NodeListResponse),
99        (status = 404, description = "Collection not found", body = ErrorResponse)
100    ),
101    tag = "graph"
102)]
103pub async fn list_nodes(
104    Path(name): Path<String>,
105    State(state): State<Arc<AppState>>,
106) -> Result<Json<NodeListResponse>, (StatusCode, Json<ErrorResponse>)> {
107    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
108    // Node enumeration takes node-store locks — run it on the blocking pool.
109    let node_ids = run_blocking_typed(move || coll.all_node_ids()).await?;
110    let count = node_ids.len();
111    Ok(Json(NodeListResponse { node_ids, count }))
112}
113
114/// Get edges for a specific node with direction filtering.
115#[utoipa::path(
116    get,
117    path = "/collections/{name}/graph/nodes/{node_id}/edges",
118    params(
119        ("name" = String, Path, description = "Collection name"),
120        ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$"),
121        NodeEdgeQueryParams
122    ),
123    responses(
124        (status = 200, description = "Node edges retrieved", body = EdgesResponse),
125        (status = 404, description = "Collection not found", body = ErrorResponse)
126    ),
127    tag = "graph"
128)]
129pub async fn get_node_edges(
130    Path((name, node_id)): Path<(String, u64)>,
131    Query(params): Query<NodeEdgeQueryParams>,
132    State(state): State<Arc<AppState>>,
133) -> Result<Json<EdgesResponse>, (StatusCode, Json<ErrorResponse>)> {
134    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
135
136    // Edge listing takes edge-store shard locks — run it on the blocking pool.
137    let direction = params.direction.to_lowercase();
138    let raw_edges = run_blocking_typed(move || match direction.as_str() {
139        "in" => coll.get_incoming(node_id),
140        "both" => {
141            let mut all = coll.get_outgoing(node_id);
142            all.extend(coll.get_incoming(node_id));
143            all
144        }
145        _ => coll.get_outgoing(node_id),
146    })
147    .await?;
148
149    let edges: Vec<EdgeResponse> = raw_edges
150        .into_iter()
151        .filter(|e| {
152            params
153                .label
154                .as_ref()
155                .is_none_or(|lbl| e.label() == lbl.as_str())
156        })
157        .map(|e| EdgeResponse {
158            id: e.id(),
159            source: e.source(),
160            target: e.target(),
161            label: e.label().to_string(),
162            properties: serde_json::to_value(e.properties()).unwrap_or_default(),
163        })
164        .collect();
165
166    let count = edges.len();
167    Ok(Json(EdgesResponse { edges, count }))
168}
169
170/// Upsert a payload on a graph node.
171#[utoipa::path(
172    put,
173    path = "/collections/{name}/graph/nodes/{node_id}/payload",
174    params(
175        ("name" = String, Path, description = "Collection name"),
176        ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
177    ),
178    request_body = UpsertNodePayloadRequest,
179    responses(
180        (status = 204, description = "Payload stored successfully"),
181        (status = 404, description = "Collection not found", body = ErrorResponse),
182        (status = 500, description = "Internal server error", body = ErrorResponse)
183    ),
184    tag = "graph"
185)]
186pub async fn upsert_node_payload(
187    Path((name, node_id)): Path<(String, u64)>,
188    State(state): State<Arc<AppState>>,
189    Json(request): Json<UpsertNodePayloadRequest>,
190) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
191    let coll = graph_preamble(&state, &name)?;
192    // Payload upsert takes write locks and persists — run it on the blocking pool.
193    run_blocking_typed(move || coll.upsert_node_payload(node_id, &request.payload))
194        .await?
195        .map_err(|e| {
196            (
197                StatusCode::INTERNAL_SERVER_ERROR,
198                Json(ErrorResponse {
199                    error: format!("Failed to store payload: {e}"),
200                    code: None,
201                }),
202            )
203        })?;
204    Ok(StatusCode::NO_CONTENT)
205}
206
207/// Get the payload of a graph node.
208#[utoipa::path(
209    get,
210    path = "/collections/{name}/graph/nodes/{node_id}/payload",
211    params(
212        ("name" = String, Path, description = "Collection name"),
213        ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
214    ),
215    responses(
216        (status = 200, description = "Payload retrieved", body = NodePayloadResponse),
217        (status = 404, description = "Collection not found", body = ErrorResponse),
218        (status = 500, description = "Internal server error", body = ErrorResponse)
219    ),
220    tag = "graph"
221)]
222pub async fn get_node_payload(
223    Path((name, node_id)): Path<(String, u64)>,
224    State(state): State<Arc<AppState>>,
225) -> Result<Json<NodePayloadResponse>, (StatusCode, Json<ErrorResponse>)> {
226    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
227    // Payload lookup takes storage locks — run it on the blocking pool.
228    let payload = run_blocking_typed(move || coll.get_node_payload(node_id))
229        .await?
230        .map_err(|e| {
231            (
232                StatusCode::INTERNAL_SERVER_ERROR,
233                Json(ErrorResponse {
234                    error: format!("Failed to get payload: {e}"),
235                    code: None,
236                }),
237            )
238        })?;
239    Ok(Json(NodePayloadResponse { node_id, payload }))
240}
241
242/// Parallel multi-source BFS traversal.
243#[utoipa::path(
244    post,
245    path = "/collections/{name}/graph/traverse/parallel",
246    request_body = ParallelTraverseRequest,
247    responses(
248        (status = 200, description = "Parallel traversal completed", body = TraverseResponse),
249        (status = 400, description = "Invalid request", body = ErrorResponse),
250        (status = 404, description = "Collection not found", body = ErrorResponse)
251    ),
252    tag = "graph"
253)]
254pub async fn traverse_parallel(
255    Path(name): Path<String>,
256    State(state): State<Arc<AppState>>,
257    Json(request): Json<ParallelTraverseRequest>,
258) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
259    if request.sources.is_empty() {
260        return Err((
261            StatusCode::BAD_REQUEST,
262            Json(ErrorResponse {
263                error: "At least one source node ID is required".to_string(),
264                code: None,
265            }),
266        ));
267    }
268
269    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
270
271    let limit = request.limit;
272    let config = TraversalConfig::with_range(1, request.max_depth)
273        .with_limit(limit)
274        .with_rel_types(request.rel_types);
275
276    // Parallel traversal is synchronous, rayon-dispatching core code — run
277    // it on the blocking pool so the async workers stay responsive.
278    let sources = request.sources;
279    let raw_results =
280        run_blocking_typed(move || coll.traverse_bfs_parallel(&sources, &config)).await?;
281
282    let results: Vec<super::types::TraversalResultItem> = raw_results
283        .into_iter()
284        .map(|r| super::types::TraversalResultItem {
285            target_id: r.target_id,
286            depth: r.depth,
287            path: r.path,
288        })
289        .collect();
290
291    let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
292    let visited = results.len();
293    let has_more = visited >= limit;
294
295    Ok(Json(TraverseResponse {
296        results,
297        has_more,
298        stats: TraversalStats {
299            visited,
300            depth_reached,
301        },
302    }))
303}
304
305/// Search graph nodes by embedding similarity.
306#[utoipa::path(
307    post,
308    path = "/collections/{name}/graph/search",
309    request_body = GraphSearchRequest,
310    responses(
311        (status = 200, description = "Graph search results", body = GraphSearchResponse),
312        (status = 400, description = "Invalid request", body = ErrorResponse),
313        (status = 404, description = "Collection not found", body = ErrorResponse),
314        (status = 500, description = "Internal server error", body = ErrorResponse)
315    ),
316    tag = "graph"
317)]
318pub async fn graph_search(
319    Path(name): Path<String>,
320    State(state): State<Arc<AppState>>,
321    Json(request): Json<GraphSearchRequest>,
322) -> Result<Json<GraphSearchResponse>, (StatusCode, Json<ErrorResponse>)> {
323    let coll = graph_preamble(&state, &name)?;
324
325    if !coll.has_embeddings() {
326        return Err((
327            StatusCode::BAD_REQUEST,
328            Json(ErrorResponse {
329                error: format!(
330                    "Graph collection '{name}' does not have embeddings. \
331                     Create it with create_graph_collection_with_embeddings() to enable search."
332                ),
333                code: None,
334            }),
335        ));
336    }
337
338    // Gate the read (CORE-2). Graph embedding search has no metadata-filter
339    // channel, so a denied or scope-narrowed decision refuses it (fail closed).
340    match state.db.authorize_read(
341        &name,
342        velesdb_core::observer::QueryOperationKind::VectorSearch,
343        None,
344        None,
345    ) {
346        Ok(None) => {}
347        Ok(Some(_)) | Err(_) => {
348            return Err((
349                StatusCode::FORBIDDEN,
350                Json(ErrorResponse {
351                    error: "Read denied by governance policy".to_string(),
352                    code: None,
353                }),
354            ));
355        }
356    }
357
358    // Embedding search is CPU-bound, lock-taking core code — run it on the
359    // blocking pool so the async workers stay responsive.
360    let search_results =
361        run_blocking_typed(move || coll.search_by_embedding(&request.vector, request.top_k))
362            .await?
363            .map_err(|e| {
364                (
365                    StatusCode::INTERNAL_SERVER_ERROR,
366                    Json(ErrorResponse {
367                        error: format!("Graph search failed: {e}"),
368                        code: None,
369                    }),
370                )
371            })?;
372
373    let results: Vec<GraphSearchResultItem> = search_results
374        .into_iter()
375        .map(|r| GraphSearchResultItem {
376            id: r.point.id,
377            score: r.score,
378            payload: r.point.payload,
379        })
380        .collect();
381
382    Ok(Json(GraphSearchResponse { results }))
383}