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
6use std::sync::Arc;
7
8use axum::{
9    extract::{Path, Query, State},
10    http::StatusCode,
11    Json,
12};
13use velesdb_core::collection::graph::{GraphEdge, TraversalConfig};
14
15use crate::types::ErrorResponse;
16use crate::AppState;
17
18use super::types::{
19    AddEdgeRequest, DegreeResponse, EdgeQueryParams, EdgeResponse, EdgesResponse, TraversalStats,
20    TraverseRequest, TraverseResponse,
21};
22
23/// Resolves a `GraphCollection` by name.
24///
25/// Returns 404 if no collection with that name exists at all.
26/// Returns 409 if a collection exists but is not a graph collection (type mismatch).
27/// Auto-creates a schemaless graph collection on first use if no collection exists yet,
28/// preserving backward compatibility with workflows that drive graph ops without
29/// an explicit `create_graph_collection` call.
30#[allow(deprecated)]
31fn get_graph_collection_or_404(
32    state: &AppState,
33    name: &str,
34) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
35    // Fast path: already registered as a graph collection.
36    if let Some(c) = state.db.get_graph_collection(name) {
37        return Ok(c);
38    }
39
40    // Check if a collection with this name exists but with a different type.
41    // Attempting to create over it would return CollectionExists — surface as 409.
42    if state.db.get_collection(name).is_some() {
43        return Err((
44            StatusCode::CONFLICT,
45            Json(ErrorResponse {
46                error: format!(
47                    "Collection '{}' exists but is not a graph collection. \
48                     Use /collections/{}/graph only on graph-typed collections.",
49                    name, name
50                ),
51                code: None,
52            }),
53        ));
54    }
55
56    // No collection at all — auto-create a schemaless graph collection.
57    use velesdb_core::GraphSchema;
58    state
59        .db
60        .create_graph_collection(name, GraphSchema::schemaless())
61        .map_err(|e| {
62            (
63                StatusCode::INTERNAL_SERVER_ERROR,
64                Json(ErrorResponse {
65                    error: format!("Failed to auto-create graph collection '{}': {e}", name),
66                    code: None,
67                }),
68            )
69        })?;
70
71    state.db.get_graph_collection(name).ok_or_else(|| {
72        (
73            StatusCode::INTERNAL_SERVER_ERROR,
74            Json(ErrorResponse {
75                error: format!("Graph collection '{}' not found after creation.", name),
76                code: None,
77            }),
78        )
79    })
80}
81
82/// Get edges from a collection's graph filtered by label.
83#[utoipa::path(
84    get,
85    path = "/collections/{name}/graph/edges",
86    params(
87        ("name" = String, Path, description = "Collection name"),
88        EdgeQueryParams
89    ),
90    responses(
91        (status = 200, description = "Edges retrieved successfully", body = EdgesResponse),
92        (status = 400, description = "Missing required 'label' query parameter", body = ErrorResponse),
93        (status = 404, description = "Collection not found", body = ErrorResponse),
94        (status = 500, description = "Internal server error", body = ErrorResponse)
95    ),
96    tag = "graph"
97)]
98pub async fn get_edges(
99    Path(name): Path<String>,
100    Query(params): Query<EdgeQueryParams>,
101    State(state): State<Arc<AppState>>,
102) -> Result<Json<EdgesResponse>, (StatusCode, Json<ErrorResponse>)> {
103    let label = params.label.ok_or_else(|| {
104        (
105            StatusCode::BAD_REQUEST,
106            Json(ErrorResponse {
107                error: "Query parameter 'label' is required. Listing all edges requires pagination (not yet implemented).".to_string(),
108                code: None,
109            }),
110        )
111    })?;
112
113    let coll = get_graph_collection_or_404(&state, &name)?;
114
115    let edges: Vec<EdgeResponse> = coll
116        .get_edges(Some(&label))
117        .into_iter()
118        .map(|e| EdgeResponse {
119            id: e.id(),
120            source: e.source(),
121            target: e.target(),
122            label: e.label().to_string(),
123            properties: serde_json::to_value(e.properties()).unwrap_or_default(),
124        })
125        .collect();
126
127    let count = edges.len();
128    Ok(Json(EdgesResponse { edges, count }))
129}
130
131/// Add an edge to a collection's graph.
132#[utoipa::path(
133    post,
134    path = "/collections/{name}/graph/edges",
135    request_body = AddEdgeRequest,
136    responses(
137        (status = 201, description = "Edge added successfully"),
138        (status = 400, description = "Invalid request", body = ErrorResponse),
139        (status = 404, description = "Collection not found", body = ErrorResponse),
140        (status = 500, description = "Internal server error", body = ErrorResponse)
141    ),
142    tag = "graph"
143)]
144pub async fn add_edge(
145    Path(name): Path<String>,
146    State(state): State<Arc<AppState>>,
147    Json(request): Json<AddEdgeRequest>,
148) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
149    let properties: std::collections::HashMap<String, serde_json::Value> = match request.properties
150    {
151        serde_json::Value::Object(map) => map.into_iter().collect(),
152        serde_json::Value::Null => std::collections::HashMap::new(),
153        _ => {
154            return Err((
155                StatusCode::BAD_REQUEST,
156                Json(ErrorResponse {
157                    error: "Properties must be an object or null".to_string(),
158                    code: None,
159                }),
160            ));
161        }
162    };
163
164    let edge = GraphEdge::new(request.id, request.source, request.target, &request.label)
165        .map_err(|e| {
166            (
167                StatusCode::BAD_REQUEST,
168                Json(ErrorResponse {
169                    error: format!("Invalid edge: {e}"),
170                    code: None,
171                }),
172            )
173        })?
174        .with_properties(properties);
175
176    let coll = get_graph_collection_or_404(&state, &name)?;
177
178    coll.add_edge(edge).map_err(|e| {
179        (
180            StatusCode::INTERNAL_SERVER_ERROR,
181            Json(ErrorResponse {
182                error: format!("Failed to add edge: {e}"),
183                code: None,
184            }),
185        )
186    })?;
187
188    Ok(StatusCode::CREATED)
189}
190
191/// Traverse the graph using BFS or DFS from a source node.
192#[utoipa::path(
193    post,
194    path = "/collections/{name}/graph/traverse",
195    request_body = TraverseRequest,
196    responses(
197        (status = 200, description = "Traversal completed successfully", body = TraverseResponse),
198        (status = 400, description = "Invalid request", body = ErrorResponse),
199        (status = 404, description = "Collection not found", body = ErrorResponse),
200        (status = 500, description = "Internal server error", body = ErrorResponse)
201    ),
202    tag = "graph"
203)]
204pub async fn traverse_graph(
205    Path(name): Path<String>,
206    State(state): State<Arc<AppState>>,
207    Json(request): Json<TraverseRequest>,
208) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
209    let coll = get_graph_collection_or_404(&state, &name)?;
210
211    let config = TraversalConfig::with_range(1, request.max_depth)
212        .with_limit(request.limit)
213        .with_rel_types(request.rel_types);
214
215    let raw_results = match request.strategy.to_lowercase().as_str() {
216        "bfs" => coll.traverse_bfs(request.source, &config),
217        "dfs" => coll.traverse_dfs(request.source, &config),
218        _ => {
219            return Err((
220                StatusCode::BAD_REQUEST,
221                Json(ErrorResponse {
222                    error: format!(
223                        "Invalid strategy '{}'. Use 'bfs' or 'dfs'.",
224                        request.strategy
225                    ),
226                    code: None,
227                }),
228            ));
229        }
230    };
231
232    // Convert TraversalResult -> TraversalResultItem
233    let results: Vec<super::types::TraversalResultItem> = raw_results
234        .into_iter()
235        .map(|r| super::types::TraversalResultItem {
236            target_id: r.target_id,
237            depth: r.depth,
238            path: r.path,
239        })
240        .collect();
241
242    let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
243    let visited = results.len();
244    let has_more = visited >= request.limit;
245
246    Ok(Json(TraverseResponse {
247        results,
248        next_cursor: None,
249        has_more,
250        stats: TraversalStats {
251            visited,
252            depth_reached,
253        },
254    }))
255}
256
257/// Get the degree (in and out) of a specific node.
258#[utoipa::path(
259    get,
260    path = "/collections/{name}/graph/nodes/{node_id}/degree",
261    params(
262        ("name" = String, Path, description = "Collection name"),
263        ("node_id" = u64, Path, description = "Node ID")
264    ),
265    responses(
266        (status = 200, description = "Degree retrieved successfully", body = DegreeResponse),
267        (status = 404, description = "Collection not found", body = ErrorResponse),
268        (status = 500, description = "Internal server error", body = ErrorResponse)
269    ),
270    tag = "graph"
271)]
272pub async fn get_node_degree(
273    Path((name, node_id)): Path<(String, u64)>,
274    State(state): State<Arc<AppState>>,
275) -> Result<Json<DegreeResponse>, (StatusCode, Json<ErrorResponse>)> {
276    let coll = get_graph_collection_or_404(&state, &name)?;
277    let (in_degree, out_degree) = coll.node_degree(node_id);
278    Ok(Json(DegreeResponse {
279        in_degree,
280        out_degree,
281    }))
282}