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::handlers::helpers::run_blocking_typed;
17use crate::types::ErrorResponse;
18use crate::AppState;
19
20use super::handlers::{graph_preamble, graph_read_preamble};
21use super::types::{
22 EdgeCountResponse, EdgeResponse, EdgesResponse, GraphSearchRequest, GraphSearchResponse,
23 GraphSearchResultItem, NodeEdgeQueryParams, NodeListResponse, NodePayloadResponse,
24 ParallelTraverseRequest, TraversalStats, TraverseResponse, UpsertNodePayloadRequest,
25};
26
27#[utoipa::path(
29 delete,
30 path = "/collections/{name}/graph/edges/{edge_id}",
31 params(
32 ("name" = String, Path, description = "Collection name"),
33 ("edge_id" = String, Path, description = "Edge ID to remove (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
34 ),
35 responses(
36 (status = 204, description = "Edge removed successfully"),
37 (status = 404, description = "Edge or collection not found", body = ErrorResponse),
38 (status = 500, description = "Internal server error", body = ErrorResponse)
39 ),
40 tag = "graph"
41)]
42pub async fn remove_edge(
43 Path((name, edge_id)): Path<(String, u64)>,
44 State(state): State<Arc<AppState>>,
45) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
46 let coll = graph_preamble(&state, &name)?;
47 if run_blocking_typed(move || coll.remove_edge(edge_id)).await? {
49 Ok(StatusCode::NO_CONTENT)
50 } else {
51 let err = velesdb_core::Error::EdgeNotFound(edge_id);
57 Err((
58 StatusCode::NOT_FOUND,
59 Json(ErrorResponse {
60 error: format!("{err} in collection '{name}'"),
61 code: Some(err.code().to_string()),
62 }),
63 ))
64 }
65}
66
67#[utoipa::path(
69 get,
70 path = "/collections/{name}/graph/edges/count",
71 params(
72 ("name" = String, Path, description = "Collection name")
73 ),
74 responses(
75 (status = 200, description = "Edge count retrieved", body = EdgeCountResponse),
76 (status = 404, description = "Collection not found", body = ErrorResponse)
77 ),
78 tag = "graph"
79)]
80pub async fn get_edge_count(
81 Path(name): Path<String>,
82 State(state): State<Arc<AppState>>,
83) -> Result<Json<EdgeCountResponse>, (StatusCode, Json<ErrorResponse>)> {
84 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
85 let count = run_blocking_typed(move || coll.edge_count()).await?;
87 Ok(Json(EdgeCountResponse { count }))
88}
89
90#[utoipa::path(
92 get,
93 path = "/collections/{name}/graph/nodes",
94 params(
95 ("name" = String, Path, description = "Collection name")
96 ),
97 responses(
98 (status = 200, description = "Node list retrieved", body = NodeListResponse),
99 (status = 404, description = "Collection not found", body = ErrorResponse)
100 ),
101 tag = "graph"
102)]
103pub async fn list_nodes(
104 Path(name): Path<String>,
105 State(state): State<Arc<AppState>>,
106) -> Result<Json<NodeListResponse>, (StatusCode, Json<ErrorResponse>)> {
107 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
108 let node_ids = run_blocking_typed(move || coll.all_node_ids()).await?;
110 let count = node_ids.len();
111 Ok(Json(NodeListResponse { node_ids, count }))
112}
113
114#[utoipa::path(
116 get,
117 path = "/collections/{name}/graph/nodes/{node_id}/edges",
118 params(
119 ("name" = String, Path, description = "Collection name"),
120 ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$"),
121 NodeEdgeQueryParams
122 ),
123 responses(
124 (status = 200, description = "Node edges retrieved", body = EdgesResponse),
125 (status = 404, description = "Collection not found", body = ErrorResponse)
126 ),
127 tag = "graph"
128)]
129pub async fn get_node_edges(
130 Path((name, node_id)): Path<(String, u64)>,
131 Query(params): Query<NodeEdgeQueryParams>,
132 State(state): State<Arc<AppState>>,
133) -> Result<Json<EdgesResponse>, (StatusCode, Json<ErrorResponse>)> {
134 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
135
136 let direction = params.direction.to_lowercase();
138 let raw_edges = run_blocking_typed(move || match direction.as_str() {
139 "in" => coll.get_incoming(node_id),
140 "both" => {
141 let mut all = coll.get_outgoing(node_id);
142 all.extend(coll.get_incoming(node_id));
143 all
144 }
145 _ => coll.get_outgoing(node_id),
146 })
147 .await?;
148
149 let edges: Vec<EdgeResponse> = raw_edges
150 .into_iter()
151 .filter(|e| {
152 params
153 .label
154 .as_ref()
155 .is_none_or(|lbl| e.label() == lbl.as_str())
156 })
157 .map(|e| EdgeResponse {
158 id: e.id(),
159 source: e.source(),
160 target: e.target(),
161 label: e.label().to_string(),
162 properties: serde_json::to_value(e.properties()).unwrap_or_default(),
163 })
164 .collect();
165
166 let count = edges.len();
167 Ok(Json(EdgesResponse { edges, count }))
168}
169
170#[utoipa::path(
172 put,
173 path = "/collections/{name}/graph/nodes/{node_id}/payload",
174 params(
175 ("name" = String, Path, description = "Collection name"),
176 ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
177 ),
178 request_body = UpsertNodePayloadRequest,
179 responses(
180 (status = 204, description = "Payload stored successfully"),
181 (status = 404, description = "Collection not found", body = ErrorResponse),
182 (status = 500, description = "Internal server error", body = ErrorResponse)
183 ),
184 tag = "graph"
185)]
186pub async fn upsert_node_payload(
187 Path((name, node_id)): Path<(String, u64)>,
188 State(state): State<Arc<AppState>>,
189 Json(request): Json<UpsertNodePayloadRequest>,
190) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
191 let coll = graph_preamble(&state, &name)?;
192 run_blocking_typed(move || coll.upsert_node_payload(node_id, &request.payload))
194 .await?
195 .map_err(|e| {
196 (
197 StatusCode::INTERNAL_SERVER_ERROR,
198 Json(ErrorResponse {
199 error: format!("Failed to store payload: {e}"),
200 code: None,
201 }),
202 )
203 })?;
204 Ok(StatusCode::NO_CONTENT)
205}
206
207#[utoipa::path(
209 get,
210 path = "/collections/{name}/graph/nodes/{node_id}/payload",
211 params(
212 ("name" = String, Path, description = "Collection name"),
213 ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
214 ),
215 responses(
216 (status = 200, description = "Payload retrieved", body = NodePayloadResponse),
217 (status = 404, description = "Collection not found", body = ErrorResponse),
218 (status = 500, description = "Internal server error", body = ErrorResponse)
219 ),
220 tag = "graph"
221)]
222pub async fn get_node_payload(
223 Path((name, node_id)): Path<(String, u64)>,
224 State(state): State<Arc<AppState>>,
225) -> Result<Json<NodePayloadResponse>, (StatusCode, Json<ErrorResponse>)> {
226 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
227 let payload = run_blocking_typed(move || coll.get_node_payload(node_id))
229 .await?
230 .map_err(|e| {
231 (
232 StatusCode::INTERNAL_SERVER_ERROR,
233 Json(ErrorResponse {
234 error: format!("Failed to get payload: {e}"),
235 code: None,
236 }),
237 )
238 })?;
239 Ok(Json(NodePayloadResponse { node_id, payload }))
240}
241
242#[utoipa::path(
244 post,
245 path = "/collections/{name}/graph/traverse/parallel",
246 request_body = ParallelTraverseRequest,
247 responses(
248 (status = 200, description = "Parallel traversal completed", body = TraverseResponse),
249 (status = 400, description = "Invalid request", body = ErrorResponse),
250 (status = 404, description = "Collection not found", body = ErrorResponse)
251 ),
252 tag = "graph"
253)]
254pub async fn traverse_parallel(
255 Path(name): Path<String>,
256 State(state): State<Arc<AppState>>,
257 Json(request): Json<ParallelTraverseRequest>,
258) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
259 if request.sources.is_empty() {
260 return Err((
261 StatusCode::BAD_REQUEST,
262 Json(ErrorResponse {
263 error: "At least one source node ID is required".to_string(),
264 code: None,
265 }),
266 ));
267 }
268
269 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
270
271 let limit = request.limit;
272 let config = TraversalConfig::with_range(1, request.max_depth)
273 .with_limit(limit)
274 .with_rel_types(request.rel_types);
275
276 let sources = request.sources;
279 let raw_results =
280 run_blocking_typed(move || coll.traverse_bfs_parallel(&sources, &config)).await?;
281
282 let results: Vec<super::types::TraversalResultItem> = raw_results
283 .into_iter()
284 .map(|r| super::types::TraversalResultItem {
285 target_id: r.target_id,
286 depth: r.depth,
287 path: r.path,
288 })
289 .collect();
290
291 let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
292 let visited = results.len();
293 let has_more = visited >= limit;
294
295 Ok(Json(TraverseResponse {
296 results,
297 has_more,
298 stats: TraversalStats {
299 visited,
300 depth_reached,
301 },
302 }))
303}
304
305#[utoipa::path(
307 post,
308 path = "/collections/{name}/graph/search",
309 request_body = GraphSearchRequest,
310 responses(
311 (status = 200, description = "Graph search results", body = GraphSearchResponse),
312 (status = 400, description = "Invalid request", body = ErrorResponse),
313 (status = 404, description = "Collection not found", body = ErrorResponse),
314 (status = 500, description = "Internal server error", body = ErrorResponse)
315 ),
316 tag = "graph"
317)]
318pub async fn graph_search(
319 Path(name): Path<String>,
320 State(state): State<Arc<AppState>>,
321 Json(request): Json<GraphSearchRequest>,
322) -> Result<Json<GraphSearchResponse>, (StatusCode, Json<ErrorResponse>)> {
323 let coll = graph_preamble(&state, &name)?;
324
325 if !coll.has_embeddings() {
326 return Err((
327 StatusCode::BAD_REQUEST,
328 Json(ErrorResponse {
329 error: format!(
330 "Graph collection '{name}' does not have embeddings. \
331 Create it with create_graph_collection_with_embeddings() to enable search."
332 ),
333 code: None,
334 }),
335 ));
336 }
337
338 match state.db.authorize_read(
341 &name,
342 velesdb_core::observer::QueryOperationKind::VectorSearch,
343 None,
344 None,
345 ) {
346 Ok(None) => {}
347 Ok(Some(_)) | Err(_) => {
348 return Err((
349 StatusCode::FORBIDDEN,
350 Json(ErrorResponse {
351 error: "Read denied by governance policy".to_string(),
352 code: None,
353 }),
354 ));
355 }
356 }
357
358 let search_results =
361 run_blocking_typed(move || coll.search_by_embedding(&request.vector, request.top_k))
362 .await?
363 .map_err(|e| {
364 (
365 StatusCode::INTERNAL_SERVER_ERROR,
366 Json(ErrorResponse {
367 error: format!("Graph search failed: {e}"),
368 code: None,
369 }),
370 )
371 })?;
372
373 let results: Vec<GraphSearchResultItem> = search_results
374 .into_iter()
375 .map(|r| GraphSearchResultItem {
376 id: r.point.id,
377 score: r.score,
378 payload: r.point.payload,
379 })
380 .collect();
381
382 Ok(Json(GraphSearchResponse { results }))
383}