Skip to main content

velesdb_server/handlers/graph/
handlers.rs

1//! Graph HTTP handlers for VelesDB REST API.
2//!
3//! All graph operations are routed through `AppState.db.get_graph_collection()`.
4//! No separate GraphService state — graph data persists via GraphCollection/GraphEngine.
5//!
6//! Extended handlers (parity endpoints) live in [`super::handlers_extended`].
7
8use std::sync::Arc;
9
10use axum::{
11    extract::{Path, Query, State},
12    http::StatusCode,
13    response::IntoResponse,
14    Json,
15};
16use velesdb_core::collection::graph::{GraphEdge, TraversalConfig};
17use velesdb_core::observer::QueryOperationKind;
18
19use crate::handlers::helpers::{auto_core_error_response, run_blocking, run_blocking_typed};
20use crate::types::ErrorResponse;
21use crate::AppState;
22
23use super::types::{
24    AddEdgeRequest, AddEdgesBatchRequest, AddEdgesBatchResponse, DegreeResponse, EdgeQueryParams,
25    EdgeResponse, EdgesResponse, TraversalStats, TraverseRequest, TraverseResponse,
26};
27
28/// Shared graph preamble: record metric and resolve collection.
29///
30/// Mirrors [`super::super::search::search_preamble`] for graph handlers.
31/// `GraphCollection` does not expose guard rails, so only the metrics
32/// recording and collection resolution steps are performed.
33#[allow(clippy::result_large_err)]
34pub(super) fn graph_preamble(
35    state: &AppState,
36    name: &str,
37) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
38    state.onboarding_metrics.record_graph_request();
39    get_graph_collection_or_404(state, name)
40}
41
42/// Resolves a `GraphCollection` by name.
43///
44/// # Returns
45///
46/// * `Ok(collection)` if a graph collection with this name exists.
47/// * `Err(404 Not Found)` if no collection with this name exists.
48/// * `Err(409 Conflict)` if a collection exists with this name but is not
49///   a graph collection (type mismatch with vector or metadata collection).
50///
51/// # Contract
52///
53/// This function previously auto-created a schemaless graph collection
54/// on first use. That behaviour is retired (F-05): a missing graph
55/// collection now yields a 404 response instead of being created
56/// silently. Callers must issue `POST /collections` with
57/// `collection_type = "graph"` before targeting graph endpoints.
58pub(super) fn get_graph_collection_or_404(
59    state: &AppState,
60    name: &str,
61) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
62    if let Some(c) = state.db.get_graph_collection(name) {
63        return Ok(c);
64    }
65
66    // Check if a non-graph collection exists with this name (type mismatch → 409).
67    if state.db.get_vector_collection(name).is_some()
68        || state.db.get_metadata_collection(name).is_some()
69    {
70        return Err((
71            StatusCode::CONFLICT,
72            Json(ErrorResponse {
73                error: format!(
74                    "Collection '{name}' exists but is not a graph collection. \
75                     Use /collections/{name}/graph only on graph-typed collections.",
76                ),
77                code: None,
78            }),
79        ));
80    }
81
82    // PR #586 Devin fix: propagate `VELES-002 CollectionNotFound` so
83    // typed-error clients surface `CollectionNotFoundError` instead of
84    // a status-derived `'NOT_FOUND'` string. The "create it first"
85    // hint stays in the message for human operators.
86    let err = velesdb_core::Error::CollectionNotFound(name.to_string());
87    Err((
88        StatusCode::NOT_FOUND,
89        Json(ErrorResponse {
90            error: format!(
91                "{err}. Create it first with \
92                 POST /collections and collection_type = \"graph\".",
93            ),
94            code: Some(err.code().to_string()),
95        }),
96    ))
97}
98
99/// Shared graph preamble for **read** operations: resolves the collection via
100/// [`graph_preamble`], then routes the read through the control-plane gate
101/// (CORE-2). Graph reads have no metadata-filter channel to narrow, so a
102/// denied or scope-narrowed decision refuses the request (fail closed)
103/// rather than running it unfiltered.
104///
105/// Mirrors the gate `MATCH` (`handlers::match_query`) and embedding
106/// [`super::handlers_extended::graph_search`] already apply — centralized
107/// here so every plain REST graph read is governed the same way instead of
108/// each handler wiring the check individually.
109#[allow(clippy::result_large_err)]
110pub(super) fn graph_read_preamble(
111    state: &AppState,
112    name: &str,
113    operation: QueryOperationKind,
114) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
115    let coll = graph_preamble(state, name)?;
116    match state.db.authorize_read(name, operation, None, None) {
117        Ok(None) => Ok(coll),
118        Ok(Some(_)) | Err(_) => Err((
119            StatusCode::FORBIDDEN,
120            Json(ErrorResponse {
121                error: "Read denied by governance policy".to_string(),
122                code: None,
123            }),
124        )),
125    }
126}
127
128/// Get edges from a collection's graph filtered by label.
129#[utoipa::path(
130    get,
131    path = "/collections/{name}/graph/edges",
132    params(("name" = String, Path, description = "Collection name"), EdgeQueryParams),
133    responses(
134        (status = 200, description = "Edges retrieved successfully", body = EdgesResponse),
135        (status = 400, description = "Missing required 'label' query parameter", body = ErrorResponse),
136        (status = 404, description = "Collection not found", body = ErrorResponse),
137        (status = 500, description = "Internal server error", body = ErrorResponse)
138    ),
139    tag = "graph"
140)]
141pub async fn get_edges(
142    Path(name): Path<String>,
143    Query(params): Query<EdgeQueryParams>,
144    State(state): State<Arc<AppState>>,
145) -> Result<Json<EdgesResponse>, (StatusCode, Json<ErrorResponse>)> {
146    let label = params.label.ok_or_else(|| {
147        (
148            StatusCode::BAD_REQUEST,
149            Json(ErrorResponse {
150                error: "Query parameter 'label' is required. Listing all edges requires pagination (not yet implemented).".to_string(),
151                code: None,
152            }),
153        )
154    })?;
155
156    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
157
158    // Edge listing takes graph store locks — run it on the blocking pool.
159    let raw_edges = run_blocking_typed(move || coll.get_edges(Some(&label))).await?;
160
161    let edges: Vec<EdgeResponse> = raw_edges
162        .into_iter()
163        .map(|e| EdgeResponse {
164            id: e.id(),
165            source: e.source(),
166            target: e.target(),
167            label: e.label().to_string(),
168            properties: serde_json::to_value(e.properties()).unwrap_or_default(),
169        })
170        .collect();
171
172    let count = edges.len();
173    Ok(Json(EdgesResponse { edges, count }))
174}
175
176/// The write-result match [`add_edge`] and [`add_edges_batch`] share: the
177/// blocking-pool outcome routed through `auto_core_error_response` (so e.g.
178/// `EdgeExists` surfaces as 409 + VELES-019, never a 500 string), success
179/// shaped by the caller. One copy, so the two handlers cannot drift on the
180/// error route.
181fn created_or_core_error<T>(
182    outcome: Result<Result<T, velesdb_core::Error>, axum::response::Response>,
183    success: impl FnOnce(T) -> axum::response::Response,
184) -> axum::response::Response {
185    match outcome {
186        Ok(Ok(value)) => success(value),
187        Ok(Err(e)) => auto_core_error_response(&e),
188        Err(resp) => resp,
189    }
190}
191
192/// Add an edge to a collection's graph.
193#[utoipa::path(
194    post,
195    path = "/collections/{name}/graph/edges",
196    request_body = AddEdgeRequest,
197    responses(
198        (status = 201, description = "Edge added successfully"),
199        (status = 400, description = "Invalid request", body = ErrorResponse),
200        (status = 404, description = "Collection not found, or source/target node has no stored payload (VELES-022 NodeNotFound)", body = ErrorResponse),
201        (status = 500, description = "Internal server error", body = ErrorResponse),
202        (status = 422, response = crate::types::MalformedBody)
203    ),
204    tag = "graph"
205)]
206pub async fn add_edge(
207    Path(name): Path<String>,
208    State(state): State<Arc<AppState>>,
209    Json(request): Json<AddEdgeRequest>,
210) -> axum::response::Response {
211    let edge = match build_edge(request) {
212        Ok(e) => e,
213        Err(resp) => return resp.into_response(),
214    };
215
216    let coll = match graph_preamble(&state, &name) {
217        Ok(c) => c,
218        Err(resp) => return resp.into_response(),
219    };
220
221    // Edge insertion takes write locks and persists — run it on the blocking
222    // pool. Route the core error through `auto_core_error_response` so e.g.
223    // `EdgeExists` surfaces as 409 + VELES-019 instead of a generic 500 string.
224    created_or_core_error(run_blocking(move || coll.add_edge(edge)).await, |()| {
225        StatusCode::CREATED.into_response()
226    })
227}
228
229/// Converts an [`AddEdgeRequest`] into a core [`GraphEdge`], validating the
230/// properties shape and edge fields. Shared by [`add_edge`] and
231/// [`add_edges_batch`].
232#[allow(clippy::result_large_err)]
233fn build_edge(request: AddEdgeRequest) -> Result<GraphEdge, (StatusCode, Json<ErrorResponse>)> {
234    let properties: std::collections::HashMap<String, serde_json::Value> = match request.properties
235    {
236        serde_json::Value::Object(map) => map.into_iter().collect(),
237        serde_json::Value::Null => std::collections::HashMap::new(),
238        _ => {
239            return Err((
240                StatusCode::BAD_REQUEST,
241                Json(ErrorResponse {
242                    error: "Properties must be an object or null".to_string(),
243                    code: None,
244                }),
245            ));
246        }
247    };
248
249    let edge = GraphEdge::new(request.id, request.source, request.target, &request.label)
250        .map_err(|e| {
251            (
252                StatusCode::BAD_REQUEST,
253                Json(ErrorResponse {
254                    error: format!("Invalid edge: {e}"),
255                    code: None,
256                }),
257            )
258        })?
259        .with_properties(properties);
260    Ok(edge)
261}
262
263/// Add multiple edges to a collection's graph in one batched operation.
264#[utoipa::path(
265    post,
266    path = "/collections/{name}/graph/edges/batch",
267    request_body = AddEdgesBatchRequest,
268    responses(
269        (status = 201, description = "Edges added successfully", body = AddEdgesBatchResponse),
270        (status = 400, description = "Invalid request", body = ErrorResponse),
271        (status = 404, description = "Collection not found, or a source/target node has no stored payload (VELES-022 NodeNotFound) — the whole batch is rejected", body = ErrorResponse),
272        (status = 500, description = "Internal server error", body = ErrorResponse),
273        (status = 422, response = crate::types::MalformedBody)
274    ),
275    tag = "graph"
276)]
277pub async fn add_edges_batch(
278    Path(name): Path<String>,
279    State(state): State<Arc<AppState>>,
280    Json(request): Json<AddEdgesBatchRequest>,
281) -> axum::response::Response {
282    let edges = match request
283        .edges
284        .into_iter()
285        .map(build_edge)
286        .collect::<Result<Vec<_>, _>>()
287    {
288        Ok(edges) => edges,
289        Err(resp) => return resp.into_response(),
290    };
291
292    let coll = match graph_preamble(&state, &name) {
293        Ok(c) => c,
294        Err(resp) => return resp.into_response(),
295    };
296
297    // Batch edge insertion takes write locks and persists — run it on the
298    // blocking pool. Route the core error through `auto_core_error_response`
299    // so e.g. `EdgeExists` surfaces as 409 + VELES-019 instead of a 500 string.
300    created_or_core_error(
301        run_blocking(move || coll.add_edges_batch(edges)).await,
302        |added| (StatusCode::CREATED, Json(AddEdgesBatchResponse { added })).into_response(),
303    )
304}
305
306/// Traverse the graph using BFS or DFS from a source node.
307#[utoipa::path(
308    post,
309    path = "/collections/{name}/graph/traverse",
310    request_body = TraverseRequest,
311    responses(
312        (status = 200, description = "Traversal completed successfully", body = TraverseResponse),
313        (status = 400, description = "Invalid request", body = ErrorResponse),
314        (status = 404, description = "Collection not found", body = ErrorResponse),
315        (status = 500, description = "Internal server error", body = ErrorResponse),
316        (status = 422, response = crate::types::MalformedBody)
317    ),
318    tag = "graph"
319)]
320pub async fn traverse_graph(
321    Path(name): Path<String>,
322    State(state): State<Arc<AppState>>,
323    Json(request): Json<TraverseRequest>,
324) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
325    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
326
327    let use_bfs = match request.strategy.to_lowercase().as_str() {
328        "bfs" => true,
329        "dfs" => false,
330        _ => {
331            return Err((
332                StatusCode::BAD_REQUEST,
333                Json(ErrorResponse {
334                    error: format!(
335                        "Invalid strategy '{}'. Use 'bfs' or 'dfs'.",
336                        request.strategy
337                    ),
338                    code: None,
339                }),
340            ));
341        }
342    };
343
344    let limit = request.limit;
345    let source = request.source;
346    let config = TraversalConfig::with_range(1, request.max_depth)
347        .with_limit(limit)
348        .with_rel_types(request.rel_types);
349
350    // Traversal is synchronous, lock-taking core code — run it on the
351    // blocking pool so the async workers stay responsive.
352    let raw_results = run_blocking_typed(move || {
353        if use_bfs {
354            coll.traverse_bfs(source, &config)
355        } else {
356            coll.traverse_dfs(source, &config)
357        }
358    })
359    .await?;
360
361    let results: Vec<super::types::TraversalResultItem> = raw_results
362        .into_iter()
363        .map(|r| super::types::TraversalResultItem {
364            target_id: r.target_id,
365            depth: r.depth,
366            path: r.path,
367        })
368        .collect();
369
370    let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
371    let visited = results.len();
372    let has_more = visited >= limit;
373
374    Ok(Json(TraverseResponse {
375        results,
376        has_more,
377        stats: TraversalStats {
378            visited,
379            depth_reached,
380        },
381    }))
382}
383
384/// Get the degree (in and out) of a specific node.
385#[utoipa::path(
386    get,
387    path = "/collections/{name}/graph/nodes/{node_id}/degree",
388    params(
389        ("name" = String, Path, description = "Collection name"),
390        ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
391    ),
392    responses(
393        (status = 200, description = "Degree retrieved successfully", body = DegreeResponse),
394        (status = 404, description = "Collection not found", body = ErrorResponse),
395        (status = 500, description = "Internal server error", body = ErrorResponse)
396    ),
397    tag = "graph"
398)]
399pub async fn get_node_degree(
400    Path((name, node_id)): Path<(String, u64)>,
401    State(state): State<Arc<AppState>>,
402) -> Result<Json<DegreeResponse>, (StatusCode, Json<ErrorResponse>)> {
403    let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
404    // Degree lookup takes edge-store shard locks — run it on the blocking pool.
405    let (in_degree, out_degree) = run_blocking_typed(move || coll.node_degree(node_id)).await?;
406    Ok(Json(DegreeResponse {
407        in_degree,
408        out_degree,
409    }))
410}