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