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 (status = 422, response = crate::types::MalformedBody)
203 ),
204 tag = "graph"
205)]
206pub async fn add_edge(
207 Path(name): Path<String>,
208 State(state): State<Arc<AppState>>,
209 Json(request): Json<AddEdgeRequest>,
210) -> axum::response::Response {
211 let edge = match build_edge(request) {
212 Ok(e) => e,
213 Err(resp) => return resp.into_response(),
214 };
215
216 let coll = match graph_preamble(&state, &name) {
217 Ok(c) => c,
218 Err(resp) => return resp.into_response(),
219 };
220
221 created_or_core_error(run_blocking(move || coll.add_edge(edge)).await, |()| {
225 StatusCode::CREATED.into_response()
226 })
227}
228
229#[allow(clippy::result_large_err)]
233fn build_edge(request: AddEdgeRequest) -> Result<GraphEdge, (StatusCode, Json<ErrorResponse>)> {
234 let properties: std::collections::HashMap<String, serde_json::Value> = match request.properties
235 {
236 serde_json::Value::Object(map) => map.into_iter().collect(),
237 serde_json::Value::Null => std::collections::HashMap::new(),
238 _ => {
239 return Err((
240 StatusCode::BAD_REQUEST,
241 Json(ErrorResponse {
242 error: "Properties must be an object or null".to_string(),
243 code: None,
244 }),
245 ));
246 }
247 };
248
249 let edge = GraphEdge::new(request.id, request.source, request.target, &request.label)
250 .map_err(|e| {
251 (
252 StatusCode::BAD_REQUEST,
253 Json(ErrorResponse {
254 error: format!("Invalid edge: {e}"),
255 code: None,
256 }),
257 )
258 })?
259 .with_properties(properties);
260 Ok(edge)
261}
262
263#[utoipa::path(
265 post,
266 path = "/collections/{name}/graph/edges/batch",
267 request_body = AddEdgesBatchRequest,
268 responses(
269 (status = 201, description = "Edges added successfully", body = AddEdgesBatchResponse),
270 (status = 400, description = "Invalid request", body = ErrorResponse),
271 (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),
272 (status = 500, description = "Internal server error", body = ErrorResponse),
273 (status = 422, response = crate::types::MalformedBody)
274 ),
275 tag = "graph"
276)]
277pub async fn add_edges_batch(
278 Path(name): Path<String>,
279 State(state): State<Arc<AppState>>,
280 Json(request): Json<AddEdgesBatchRequest>,
281) -> axum::response::Response {
282 let edges = match request
283 .edges
284 .into_iter()
285 .map(build_edge)
286 .collect::<Result<Vec<_>, _>>()
287 {
288 Ok(edges) => edges,
289 Err(resp) => return resp.into_response(),
290 };
291
292 let coll = match graph_preamble(&state, &name) {
293 Ok(c) => c,
294 Err(resp) => return resp.into_response(),
295 };
296
297 created_or_core_error(
301 run_blocking(move || coll.add_edges_batch(edges)).await,
302 |added| (StatusCode::CREATED, Json(AddEdgesBatchResponse { added })).into_response(),
303 )
304}
305
306#[utoipa::path(
308 post,
309 path = "/collections/{name}/graph/traverse",
310 request_body = TraverseRequest,
311 responses(
312 (status = 200, description = "Traversal completed successfully", body = TraverseResponse),
313 (status = 400, description = "Invalid request", body = ErrorResponse),
314 (status = 404, description = "Collection not found", body = ErrorResponse),
315 (status = 500, description = "Internal server error", body = ErrorResponse),
316 (status = 422, response = crate::types::MalformedBody)
317 ),
318 tag = "graph"
319)]
320pub async fn traverse_graph(
321 Path(name): Path<String>,
322 State(state): State<Arc<AppState>>,
323 Json(request): Json<TraverseRequest>,
324) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
325 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
326
327 let use_bfs = match request.strategy.to_lowercase().as_str() {
328 "bfs" => true,
329 "dfs" => false,
330 _ => {
331 return Err((
332 StatusCode::BAD_REQUEST,
333 Json(ErrorResponse {
334 error: format!(
335 "Invalid strategy '{}'. Use 'bfs' or 'dfs'.",
336 request.strategy
337 ),
338 code: None,
339 }),
340 ));
341 }
342 };
343
344 let limit = request.limit;
345 let source = request.source;
346 let config = TraversalConfig::with_range(1, request.max_depth)
347 .with_limit(limit)
348 .with_rel_types(request.rel_types);
349
350 let raw_results = run_blocking_typed(move || {
353 if use_bfs {
354 coll.traverse_bfs(source, &config)
355 } else {
356 coll.traverse_dfs(source, &config)
357 }
358 })
359 .await?;
360
361 let results: Vec<super::types::TraversalResultItem> = raw_results
362 .into_iter()
363 .map(|r| super::types::TraversalResultItem {
364 target_id: r.target_id,
365 depth: r.depth,
366 path: r.path,
367 })
368 .collect();
369
370 let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
371 let visited = results.len();
372 let has_more = visited >= limit;
373
374 Ok(Json(TraverseResponse {
375 results,
376 has_more,
377 stats: TraversalStats {
378 visited,
379 depth_reached,
380 },
381 }))
382}
383
384#[utoipa::path(
386 get,
387 path = "/collections/{name}/graph/nodes/{node_id}/degree",
388 params(
389 ("name" = String, Path, description = "Collection name"),
390 ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
391 ),
392 responses(
393 (status = 200, description = "Degree retrieved successfully", body = DegreeResponse),
394 (status = 404, description = "Collection not found", body = ErrorResponse),
395 (status = 500, description = "Internal server error", body = ErrorResponse)
396 ),
397 tag = "graph"
398)]
399pub async fn get_node_degree(
400 Path((name, node_id)): Path<(String, u64)>,
401 State(state): State<Arc<AppState>>,
402) -> Result<Json<DegreeResponse>, (StatusCode, Json<ErrorResponse>)> {
403 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
404 let (in_degree, out_degree) = run_blocking_typed(move || coll.node_degree(node_id)).await?;
406 Ok(Json(DegreeResponse {
407 in_degree,
408 out_degree,
409 }))
410}