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    Json,
14};
15use velesdb_core::collection::graph::{GraphEdge, TraversalConfig};
16
17use crate::types::ErrorResponse;
18use crate::AppState;
19
20use super::types::{
21    AddEdgeRequest, AddEdgesBatchRequest, AddEdgesBatchResponse, DegreeResponse, EdgeQueryParams,
22    EdgeResponse, EdgesResponse, TraversalStats, TraverseRequest, TraverseResponse,
23};
24
25/// Shared graph preamble: record metric and resolve collection.
26///
27/// Mirrors [`super::super::search::search_preamble`] for graph handlers.
28/// `GraphCollection` does not expose guard rails, so only the metrics
29/// recording and collection resolution steps are performed.
30#[allow(clippy::result_large_err)]
31pub(super) fn graph_preamble(
32    state: &AppState,
33    name: &str,
34) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
35    state.onboarding_metrics.record_graph_request();
36    get_graph_collection_or_404(state, name)
37}
38
39/// Resolves a `GraphCollection` by name.
40///
41/// # Returns
42///
43/// * `Ok(collection)` if a graph collection with this name exists.
44/// * `Err(404 Not Found)` if no collection with this name exists.
45/// * `Err(409 Conflict)` if a collection exists with this name but is not
46///   a graph collection (type mismatch with vector or metadata collection).
47///
48/// # Contract
49///
50/// This function previously auto-created a schemaless graph collection
51/// on first use. That behaviour is retired (F-05): a missing graph
52/// collection now yields a 404 response instead of being created
53/// silently. Callers must issue `POST /collections` with
54/// `collection_type = "graph"` before targeting graph endpoints.
55pub(super) fn get_graph_collection_or_404(
56    state: &AppState,
57    name: &str,
58) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
59    if let Some(c) = state.db.get_graph_collection(name) {
60        return Ok(c);
61    }
62
63    // Check if a non-graph collection exists with this name (type mismatch → 409).
64    if state.db.get_vector_collection(name).is_some()
65        || state.db.get_metadata_collection(name).is_some()
66    {
67        return Err((
68            StatusCode::CONFLICT,
69            Json(ErrorResponse {
70                error: format!(
71                    "Collection '{name}' exists but is not a graph collection. \
72                     Use /collections/{name}/graph only on graph-typed collections.",
73                ),
74                code: None,
75            }),
76        ));
77    }
78
79    // PR #586 Devin fix: propagate `VELES-002 CollectionNotFound` so
80    // typed-error clients surface `CollectionNotFoundError` instead of
81    // a status-derived `'NOT_FOUND'` string. The "create it first"
82    // hint stays in the message for human operators.
83    let err = velesdb_core::Error::CollectionNotFound(name.to_string());
84    Err((
85        StatusCode::NOT_FOUND,
86        Json(ErrorResponse {
87            error: format!(
88                "{err}. Create it first with \
89                 POST /collections and collection_type = \"graph\".",
90            ),
91            code: Some(err.code().to_string()),
92        }),
93    ))
94}
95
96/// Get edges from a collection's graph filtered by label.
97#[utoipa::path(
98    get,
99    path = "/collections/{name}/graph/edges",
100    params(("name" = String, Path, description = "Collection name"), EdgeQueryParams),
101    responses(
102        (status = 200, description = "Edges retrieved successfully", body = EdgesResponse),
103        (status = 400, description = "Missing required 'label' query parameter", body = ErrorResponse),
104        (status = 404, description = "Collection not found", body = ErrorResponse),
105        (status = 500, description = "Internal server error", body = ErrorResponse)
106    ),
107    tag = "graph"
108)]
109pub async fn get_edges(
110    Path(name): Path<String>,
111    Query(params): Query<EdgeQueryParams>,
112    State(state): State<Arc<AppState>>,
113) -> Result<Json<EdgesResponse>, (StatusCode, Json<ErrorResponse>)> {
114    let label = params.label.ok_or_else(|| {
115        (
116            StatusCode::BAD_REQUEST,
117            Json(ErrorResponse {
118                error: "Query parameter 'label' is required. Listing all edges requires pagination (not yet implemented).".to_string(),
119                code: None,
120            }),
121        )
122    })?;
123
124    let coll = graph_preamble(&state, &name)?;
125
126    let edges: Vec<EdgeResponse> = coll
127        .get_edges(Some(&label))
128        .into_iter()
129        .map(|e| EdgeResponse {
130            id: e.id(),
131            source: e.source(),
132            target: e.target(),
133            label: e.label().to_string(),
134            properties: serde_json::to_value(e.properties()).unwrap_or_default(),
135        })
136        .collect();
137
138    let count = edges.len();
139    Ok(Json(EdgesResponse { edges, count }))
140}
141
142/// Add an edge to a collection's graph.
143#[utoipa::path(
144    post,
145    path = "/collections/{name}/graph/edges",
146    request_body = AddEdgeRequest,
147    responses(
148        (status = 201, description = "Edge added successfully"),
149        (status = 400, description = "Invalid request", body = ErrorResponse),
150        (status = 404, description = "Collection not found", body = ErrorResponse),
151        (status = 500, description = "Internal server error", body = ErrorResponse)
152    ),
153    tag = "graph"
154)]
155pub async fn add_edge(
156    Path(name): Path<String>,
157    State(state): State<Arc<AppState>>,
158    Json(request): Json<AddEdgeRequest>,
159) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
160    let edge = build_edge(request)?;
161
162    let coll = graph_preamble(&state, &name)?;
163
164    coll.add_edge(edge).map_err(|e| {
165        (
166            StatusCode::INTERNAL_SERVER_ERROR,
167            Json(ErrorResponse {
168                error: format!("Failed to add edge: {e}"),
169                code: None,
170            }),
171        )
172    })?;
173
174    Ok(StatusCode::CREATED)
175}
176
177/// Converts an [`AddEdgeRequest`] into a core [`GraphEdge`], validating the
178/// properties shape and edge fields. Shared by [`add_edge`] and
179/// [`add_edges_batch`].
180#[allow(clippy::result_large_err)]
181fn build_edge(request: AddEdgeRequest) -> Result<GraphEdge, (StatusCode, Json<ErrorResponse>)> {
182    let properties: std::collections::HashMap<String, serde_json::Value> = match request.properties
183    {
184        serde_json::Value::Object(map) => map.into_iter().collect(),
185        serde_json::Value::Null => std::collections::HashMap::new(),
186        _ => {
187            return Err((
188                StatusCode::BAD_REQUEST,
189                Json(ErrorResponse {
190                    error: "Properties must be an object or null".to_string(),
191                    code: None,
192                }),
193            ));
194        }
195    };
196
197    let edge = GraphEdge::new(request.id, request.source, request.target, &request.label)
198        .map_err(|e| {
199            (
200                StatusCode::BAD_REQUEST,
201                Json(ErrorResponse {
202                    error: format!("Invalid edge: {e}"),
203                    code: None,
204                }),
205            )
206        })?
207        .with_properties(properties);
208    Ok(edge)
209}
210
211/// Add multiple edges to a collection's graph in one batched operation.
212#[utoipa::path(
213    post,
214    path = "/collections/{name}/graph/edges/batch",
215    request_body = AddEdgesBatchRequest,
216    responses(
217        (status = 201, description = "Edges added successfully", body = AddEdgesBatchResponse),
218        (status = 400, description = "Invalid request", body = ErrorResponse),
219        (status = 404, description = "Collection not found", body = ErrorResponse),
220        (status = 500, description = "Internal server error", body = ErrorResponse)
221    ),
222    tag = "graph"
223)]
224pub async fn add_edges_batch(
225    Path(name): Path<String>,
226    State(state): State<Arc<AppState>>,
227    Json(request): Json<AddEdgesBatchRequest>,
228) -> Result<(StatusCode, Json<AddEdgesBatchResponse>), (StatusCode, Json<ErrorResponse>)> {
229    let edges = request
230        .edges
231        .into_iter()
232        .map(build_edge)
233        .collect::<Result<Vec<_>, _>>()?;
234
235    let coll = graph_preamble(&state, &name)?;
236
237    let added = coll.add_edges_batch(edges).map_err(|e| {
238        (
239            StatusCode::INTERNAL_SERVER_ERROR,
240            Json(ErrorResponse {
241                error: format!("Failed to add edges: {e}"),
242                code: None,
243            }),
244        )
245    })?;
246
247    Ok((StatusCode::CREATED, Json(AddEdgesBatchResponse { added })))
248}
249
250/// Traverse the graph using BFS or DFS from a source node.
251#[utoipa::path(
252    post,
253    path = "/collections/{name}/graph/traverse",
254    request_body = TraverseRequest,
255    responses(
256        (status = 200, description = "Traversal completed successfully", body = TraverseResponse),
257        (status = 400, description = "Invalid request", body = ErrorResponse),
258        (status = 404, description = "Collection not found", body = ErrorResponse),
259        (status = 500, description = "Internal server error", body = ErrorResponse)
260    ),
261    tag = "graph"
262)]
263pub async fn traverse_graph(
264    Path(name): Path<String>,
265    State(state): State<Arc<AppState>>,
266    Json(request): Json<TraverseRequest>,
267) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
268    let coll = graph_preamble(&state, &name)?;
269
270    let config = TraversalConfig::with_range(1, request.max_depth)
271        .with_limit(request.limit)
272        .with_rel_types(request.rel_types);
273
274    let raw_results = match request.strategy.to_lowercase().as_str() {
275        "bfs" => coll.traverse_bfs(request.source, &config),
276        "dfs" => coll.traverse_dfs(request.source, &config),
277        _ => {
278            return Err((
279                StatusCode::BAD_REQUEST,
280                Json(ErrorResponse {
281                    error: format!(
282                        "Invalid strategy '{}'. Use 'bfs' or 'dfs'.",
283                        request.strategy
284                    ),
285                    code: None,
286                }),
287            ));
288        }
289    };
290
291    let results: Vec<super::types::TraversalResultItem> = raw_results
292        .into_iter()
293        .map(|r| super::types::TraversalResultItem {
294            target_id: r.target_id,
295            depth: r.depth,
296            path: r.path,
297        })
298        .collect();
299
300    let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
301    let visited = results.len();
302    let has_more = visited >= request.limit;
303
304    Ok(Json(TraverseResponse {
305        results,
306        has_more,
307        stats: TraversalStats {
308            visited,
309            depth_reached,
310        },
311    }))
312}
313
314/// Get the degree (in and out) of a specific node.
315#[utoipa::path(
316    get,
317    path = "/collections/{name}/graph/nodes/{node_id}/degree",
318    params(
319        ("name" = String, Path, description = "Collection name"),
320        ("node_id" = u64, Path, description = "Node ID")
321    ),
322    responses(
323        (status = 200, description = "Degree retrieved successfully", body = DegreeResponse),
324        (status = 404, description = "Collection not found", body = ErrorResponse),
325        (status = 500, description = "Internal server error", body = ErrorResponse)
326    ),
327    tag = "graph"
328)]
329pub async fn get_node_degree(
330    Path((name, node_id)): Path<(String, u64)>,
331    State(state): State<Arc<AppState>>,
332) -> Result<Json<DegreeResponse>, (StatusCode, Json<ErrorResponse>)> {
333    let coll = graph_preamble(&state, &name)?;
334    let (in_degree, out_degree) = coll.node_degree(node_id);
335    Ok(Json(DegreeResponse {
336        in_degree,
337        out_degree,
338    }))
339}