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