1use 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};
17use velesdb_core::observer::QueryOperationKind;
18
19use crate::handlers::helpers::{auto_core_error_response, run_blocking, run_blocking_typed};
20use crate::types::ErrorResponse;
21use crate::AppState;
22
23use super::types::{
24 AddEdgeRequest, AddEdgesBatchRequest, AddEdgesBatchResponse, DegreeResponse, EdgeQueryParams,
25 EdgeResponse, EdgesResponse, TraversalStats, TraverseRequest, TraverseResponse,
26};
27
28#[allow(clippy::result_large_err)]
34pub(super) fn graph_preamble(
35 state: &AppState,
36 name: &str,
37) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
38 state.onboarding_metrics.record_graph_request();
39 get_graph_collection_or_404(state, name)
40}
41
42pub(super) fn get_graph_collection_or_404(
59 state: &AppState,
60 name: &str,
61) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
62 if let Some(c) = state.db.get_graph_collection(name) {
63 return Ok(c);
64 }
65
66 if state.db.get_vector_collection(name).is_some()
68 || state.db.get_metadata_collection(name).is_some()
69 {
70 return Err((
71 StatusCode::CONFLICT,
72 Json(ErrorResponse {
73 error: format!(
74 "Collection '{name}' exists but is not a graph collection. \
75 Use /collections/{name}/graph only on graph-typed collections.",
76 ),
77 code: None,
78 }),
79 ));
80 }
81
82 let err = velesdb_core::Error::CollectionNotFound(name.to_string());
87 Err((
88 StatusCode::NOT_FOUND,
89 Json(ErrorResponse {
90 error: format!(
91 "{err}. Create it first with \
92 POST /collections and collection_type = \"graph\".",
93 ),
94 code: Some(err.code().to_string()),
95 }),
96 ))
97}
98
99#[allow(clippy::result_large_err)]
110pub(super) fn graph_read_preamble(
111 state: &AppState,
112 name: &str,
113 operation: QueryOperationKind,
114) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
115 let coll = graph_preamble(state, name)?;
116 match state.db.authorize_read(name, operation, None, None) {
117 Ok(None) => Ok(coll),
118 Ok(Some(_)) | Err(_) => Err((
119 StatusCode::FORBIDDEN,
120 Json(ErrorResponse {
121 error: "Read denied by governance policy".to_string(),
122 code: None,
123 }),
124 )),
125 }
126}
127
128#[utoipa::path(
130 get,
131 path = "/collections/{name}/graph/edges",
132 params(("name" = String, Path, description = "Collection name"), EdgeQueryParams),
133 responses(
134 (status = 200, description = "Edges retrieved successfully", body = EdgesResponse),
135 (status = 400, description = "Missing required 'label' query parameter", body = ErrorResponse),
136 (status = 404, description = "Collection not found", body = ErrorResponse),
137 (status = 500, description = "Internal server error", body = ErrorResponse)
138 ),
139 tag = "graph"
140)]
141pub async fn get_edges(
142 Path(name): Path<String>,
143 Query(params): Query<EdgeQueryParams>,
144 State(state): State<Arc<AppState>>,
145) -> Result<Json<EdgesResponse>, (StatusCode, Json<ErrorResponse>)> {
146 let label = params.label.ok_or_else(|| {
147 (
148 StatusCode::BAD_REQUEST,
149 Json(ErrorResponse {
150 error: "Query parameter 'label' is required. Listing all edges requires pagination (not yet implemented).".to_string(),
151 code: None,
152 }),
153 )
154 })?;
155
156 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
157
158 let raw_edges = run_blocking_typed(move || coll.get_edges(Some(&label))).await?;
160
161 let edges: Vec<EdgeResponse> = raw_edges
162 .into_iter()
163 .map(|e| EdgeResponse {
164 id: e.id(),
165 source: e.source(),
166 target: e.target(),
167 label: e.label().to_string(),
168 properties: serde_json::to_value(e.properties()).unwrap_or_default(),
169 })
170 .collect();
171
172 let count = edges.len();
173 Ok(Json(EdgesResponse { edges, count }))
174}
175
176fn created_or_core_error<T>(
182 outcome: Result<Result<T, velesdb_core::Error>, axum::response::Response>,
183 success: impl FnOnce(T) -> axum::response::Response,
184) -> axum::response::Response {
185 match outcome {
186 Ok(Ok(value)) => success(value),
187 Ok(Err(e)) => auto_core_error_response(&e),
188 Err(resp) => resp,
189 }
190}
191
192#[utoipa::path(
194 post,
195 path = "/collections/{name}/graph/edges",
196 request_body = AddEdgeRequest,
197 responses(
198 (status = 201, description = "Edge added successfully"),
199 (status = 400, description = "Invalid request", body = ErrorResponse),
200 (status = 404, description = "Collection not found, or source/target node has no stored payload (VELES-022 NodeNotFound)", body = ErrorResponse),
201 (status = 500, description = "Internal server error", body = ErrorResponse)
202 ),
203 tag = "graph"
204)]
205pub async fn add_edge(
206 Path(name): Path<String>,
207 State(state): State<Arc<AppState>>,
208 Json(request): Json<AddEdgeRequest>,
209) -> axum::response::Response {
210 let edge = match build_edge(request) {
211 Ok(e) => e,
212 Err(resp) => return resp.into_response(),
213 };
214
215 let coll = match graph_preamble(&state, &name) {
216 Ok(c) => c,
217 Err(resp) => return resp.into_response(),
218 };
219
220 created_or_core_error(run_blocking(move || coll.add_edge(edge)).await, |()| {
224 StatusCode::CREATED.into_response()
225 })
226}
227
228#[allow(clippy::result_large_err)]
232fn build_edge(request: AddEdgeRequest) -> Result<GraphEdge, (StatusCode, Json<ErrorResponse>)> {
233 let properties: std::collections::HashMap<String, serde_json::Value> = match request.properties
234 {
235 serde_json::Value::Object(map) => map.into_iter().collect(),
236 serde_json::Value::Null => std::collections::HashMap::new(),
237 _ => {
238 return Err((
239 StatusCode::BAD_REQUEST,
240 Json(ErrorResponse {
241 error: "Properties must be an object or null".to_string(),
242 code: None,
243 }),
244 ));
245 }
246 };
247
248 let edge = GraphEdge::new(request.id, request.source, request.target, &request.label)
249 .map_err(|e| {
250 (
251 StatusCode::BAD_REQUEST,
252 Json(ErrorResponse {
253 error: format!("Invalid edge: {e}"),
254 code: None,
255 }),
256 )
257 })?
258 .with_properties(properties);
259 Ok(edge)
260}
261
262#[utoipa::path(
264 post,
265 path = "/collections/{name}/graph/edges/batch",
266 request_body = AddEdgesBatchRequest,
267 responses(
268 (status = 201, description = "Edges added successfully", body = AddEdgesBatchResponse),
269 (status = 400, description = "Invalid request", body = ErrorResponse),
270 (status = 404, description = "Collection not found, or a source/target node has no stored payload (VELES-022 NodeNotFound) — the whole batch is rejected", body = ErrorResponse),
271 (status = 500, description = "Internal server error", body = ErrorResponse)
272 ),
273 tag = "graph"
274)]
275pub async fn add_edges_batch(
276 Path(name): Path<String>,
277 State(state): State<Arc<AppState>>,
278 Json(request): Json<AddEdgesBatchRequest>,
279) -> axum::response::Response {
280 let edges = match request
281 .edges
282 .into_iter()
283 .map(build_edge)
284 .collect::<Result<Vec<_>, _>>()
285 {
286 Ok(edges) => edges,
287 Err(resp) => return resp.into_response(),
288 };
289
290 let coll = match graph_preamble(&state, &name) {
291 Ok(c) => c,
292 Err(resp) => return resp.into_response(),
293 };
294
295 created_or_core_error(
299 run_blocking(move || coll.add_edges_batch(edges)).await,
300 |added| (StatusCode::CREATED, Json(AddEdgesBatchResponse { added })).into_response(),
301 )
302}
303
304#[utoipa::path(
306 post,
307 path = "/collections/{name}/graph/traverse",
308 request_body = TraverseRequest,
309 responses(
310 (status = 200, description = "Traversal completed successfully", body = TraverseResponse),
311 (status = 400, description = "Invalid request", body = ErrorResponse),
312 (status = 404, description = "Collection not found", body = ErrorResponse),
313 (status = 500, description = "Internal server error", body = ErrorResponse)
314 ),
315 tag = "graph"
316)]
317pub async fn traverse_graph(
318 Path(name): Path<String>,
319 State(state): State<Arc<AppState>>,
320 Json(request): Json<TraverseRequest>,
321) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
322 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
323
324 let use_bfs = match request.strategy.to_lowercase().as_str() {
325 "bfs" => true,
326 "dfs" => false,
327 _ => {
328 return Err((
329 StatusCode::BAD_REQUEST,
330 Json(ErrorResponse {
331 error: format!(
332 "Invalid strategy '{}'. Use 'bfs' or 'dfs'.",
333 request.strategy
334 ),
335 code: None,
336 }),
337 ));
338 }
339 };
340
341 let limit = request.limit;
342 let source = request.source;
343 let config = TraversalConfig::with_range(1, request.max_depth)
344 .with_limit(limit)
345 .with_rel_types(request.rel_types);
346
347 let raw_results = run_blocking_typed(move || {
350 if use_bfs {
351 coll.traverse_bfs(source, &config)
352 } else {
353 coll.traverse_dfs(source, &config)
354 }
355 })
356 .await?;
357
358 let results: Vec<super::types::TraversalResultItem> = raw_results
359 .into_iter()
360 .map(|r| super::types::TraversalResultItem {
361 target_id: r.target_id,
362 depth: r.depth,
363 path: r.path,
364 })
365 .collect();
366
367 let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
368 let visited = results.len();
369 let has_more = visited >= limit;
370
371 Ok(Json(TraverseResponse {
372 results,
373 has_more,
374 stats: TraversalStats {
375 visited,
376 depth_reached,
377 },
378 }))
379}
380
381#[utoipa::path(
383 get,
384 path = "/collections/{name}/graph/nodes/{node_id}/degree",
385 params(
386 ("name" = String, Path, description = "Collection name"),
387 ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
388 ),
389 responses(
390 (status = 200, description = "Degree retrieved successfully", body = DegreeResponse),
391 (status = 404, description = "Collection not found", body = ErrorResponse),
392 (status = 500, description = "Internal server error", body = ErrorResponse)
393 ),
394 tag = "graph"
395)]
396pub async fn get_node_degree(
397 Path((name, node_id)): Path<(String, u64)>,
398 State(state): State<Arc<AppState>>,
399) -> Result<Json<DegreeResponse>, (StatusCode, Json<ErrorResponse>)> {
400 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
401 let (in_degree, out_degree) = run_blocking_typed(move || coll.node_degree(node_id)).await?;
403 Ok(Json(DegreeResponse {
404 in_degree,
405 out_degree,
406 }))
407}