1use 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#[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 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#[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#[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#[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#[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#[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#[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#[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 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}