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        (status = 422, response = crate::types::MalformedBody)
184    ),
185    tag = "graph"
186)]
187pub async fn upsert_node_payload(
188    Path((name, node_id)): Path<(String, u64)>,
189    State(state): State<Arc<AppState>>,
190    Json(request): Json<UpsertNodePayloadRequest>,
191) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
192    let coll = graph_preamble(&state, &name)?;
193    // Payload upsert takes write locks and persists — run it on the blocking pool.
194    run_blocking_typed(move || coll.upsert_node_payload(node_id, &request.payload))
195        .await?
196        .map_err(|e| {
197            (
198                StatusCode::INTERNAL_SERVER_ERROR,
199                Json(ErrorResponse {
200                    error: format!("Failed to store payload: {e}"),
201                    code: None,
202                }),
203            )
204        })?;
205    Ok(StatusCode::NO_CONTENT)
206}
207
208/// Get the payload of a graph node.
209#[utoipa::path(
210    get,
211    path = "/collections/{name}/graph/nodes/{node_id}/payload",
212    params(
213        ("name" = String, Path, description = "Collection name"),
214        ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
215    ),
216    responses(
217        (status = 200, description = "Payload retrieved", body = NodePayloadResponse),
218        (status = 404, description = "Collection not found", body = ErrorResponse),
219        (status = 500, description = "Internal server error", body = ErrorResponse)
220    ),
221    tag = "graph"
222)]
223pub async fn get_node_payload(
224    Path((name, node_id)): Path<(String, u64)>,
225    State(state): State<Arc<AppState>>,
226) -> Result<Json<NodePayloadResponse>, (StatusCode, Json<ErrorResponse>)> {
227    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
228    // Payload lookup takes storage locks — run it on the blocking pool.
229    let payload = run_blocking_typed(move || coll.get_node_payload(node_id))
230        .await?
231        .map_err(|e| {
232            (
233                StatusCode::INTERNAL_SERVER_ERROR,
234                Json(ErrorResponse {
235                    error: format!("Failed to get payload: {e}"),
236                    code: None,
237                }),
238            )
239        })?;
240    Ok(Json(NodePayloadResponse { node_id, payload }))
241}
242
243/// Parallel multi-source BFS traversal.
244#[utoipa::path(
245    post,
246    path = "/collections/{name}/graph/traverse/parallel",
247    request_body = ParallelTraverseRequest,
248    responses(
249        (status = 200, description = "Parallel traversal completed", body = TraverseResponse),
250        (status = 400, description = "Invalid request", body = ErrorResponse),
251        (status = 404, description = "Collection not found", body = ErrorResponse),
252        (status = 422, response = crate::types::MalformedBody)
253    ),
254    tag = "graph"
255)]
256pub async fn traverse_parallel(
257    Path(name): Path<String>,
258    State(state): State<Arc<AppState>>,
259    Json(request): Json<ParallelTraverseRequest>,
260) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
261    if request.sources.is_empty() {
262        return Err((
263            StatusCode::BAD_REQUEST,
264            Json(ErrorResponse {
265                error: "At least one source node ID is required".to_string(),
266                code: None,
267            }),
268        ));
269    }
270
271    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
272
273    let limit = request.limit;
274    let config = TraversalConfig::with_range(1, request.max_depth)
275        .with_limit(limit)
276        .with_rel_types(request.rel_types);
277
278    // Parallel traversal is synchronous, rayon-dispatching core code — run
279    // it on the blocking pool so the async workers stay responsive.
280    let sources = request.sources;
281    let raw_results =
282        run_blocking_typed(move || coll.traverse_bfs_parallel(&sources, &config)).await?;
283
284    let results: Vec<super::types::TraversalResultItem> = raw_results
285        .into_iter()
286        .map(|r| super::types::TraversalResultItem {
287            target_id: r.target_id,
288            depth: r.depth,
289            path: r.path,
290        })
291        .collect();
292
293    let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
294    let visited = results.len();
295    let has_more = visited >= limit;
296
297    Ok(Json(TraverseResponse {
298        results,
299        has_more,
300        stats: TraversalStats {
301            visited,
302            depth_reached,
303        },
304    }))
305}
306
307/// Search graph nodes by embedding similarity.
308#[utoipa::path(
309    post,
310    path = "/collections/{name}/graph/search",
311    request_body = GraphSearchRequest,
312    responses(
313        (status = 200, description = "Graph search results", body = GraphSearchResponse),
314        (status = 400, description = "Invalid request", body = ErrorResponse),
315        (status = 404, description = "Collection not found", body = ErrorResponse),
316        (status = 500, description = "Internal server error", body = ErrorResponse),
317        (status = 422, response = crate::types::MalformedBody)
318    ),
319    tag = "graph"
320)]
321pub async fn graph_search(
322    Path(name): Path<String>,
323    State(state): State<Arc<AppState>>,
324    Json(request): Json<GraphSearchRequest>,
325) -> Result<Json<GraphSearchResponse>, (StatusCode, Json<ErrorResponse>)> {
326    let coll = graph_preamble(&state, &name)?;
327
328    if !coll.has_embeddings() {
329        return Err((
330            StatusCode::BAD_REQUEST,
331            Json(ErrorResponse {
332                error: format!(
333                    "Graph collection '{name}' does not have embeddings. \
334                     Create it with create_graph_collection_with_embeddings() to enable search."
335                ),
336                code: None,
337            }),
338        ));
339    }
340
341    // Gate the read (CORE-2). Graph embedding search has no metadata-filter
342    // channel, so a denied or scope-narrowed decision refuses it (fail closed).
343    match state.db.authorize_read(
344        &name,
345        velesdb_core::observer::QueryOperationKind::VectorSearch,
346        None,
347        None,
348    ) {
349        Ok(None) => {}
350        Ok(Some(_)) | Err(_) => {
351            return Err((
352                StatusCode::FORBIDDEN,
353                Json(ErrorResponse {
354                    error: "Read denied by governance policy".to_string(),
355                    code: None,
356                }),
357            ));
358        }
359    }
360
361    // Embedding search is CPU-bound, lock-taking core code — run it on the
362    // blocking pool so the async workers stay responsive.
363    let search_results =
364        run_blocking_typed(move || coll.search_by_embedding(&request.vector, request.top_k))
365            .await?
366            .map_err(|e| {
367                (
368                    StatusCode::INTERNAL_SERVER_ERROR,
369                    Json(ErrorResponse {
370                        error: format!("Graph search failed: {e}"),
371                        code: None,
372                    }),
373                )
374            })?;
375
376    let results: Vec<GraphSearchResultItem> = search_results
377        .into_iter()
378        .map(|r| GraphSearchResultItem {
379            id: r.point.id,
380            score: r.score,
381            payload: r.point.payload,
382        })
383        .collect();
384
385    Ok(Json(GraphSearchResponse { results }))
386}