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;
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 edges: Vec<EdgeResponse> = coll
159 .get_edges(Some(&label))
160 .into_iter()
161 .map(|e| EdgeResponse {
162 id: e.id(),
163 source: e.source(),
164 target: e.target(),
165 label: e.label().to_string(),
166 properties: serde_json::to_value(e.properties()).unwrap_or_default(),
167 })
168 .collect();
169
170 let count = edges.len();
171 Ok(Json(EdgesResponse { edges, count }))
172}
173
174#[utoipa::path(
176 post,
177 path = "/collections/{name}/graph/edges",
178 request_body = AddEdgeRequest,
179 responses(
180 (status = 201, description = "Edge added successfully"),
181 (status = 400, description = "Invalid request", body = ErrorResponse),
182 (status = 404, description = "Collection not found, or source/target node has no stored payload (VELES-022 NodeNotFound)", body = ErrorResponse),
183 (status = 500, description = "Internal server error", body = ErrorResponse)
184 ),
185 tag = "graph"
186)]
187pub async fn add_edge(
188 Path(name): Path<String>,
189 State(state): State<Arc<AppState>>,
190 Json(request): Json<AddEdgeRequest>,
191) -> axum::response::Response {
192 let edge = match build_edge(request) {
193 Ok(e) => e,
194 Err(resp) => return resp.into_response(),
195 };
196
197 let coll = match graph_preamble(&state, &name) {
198 Ok(c) => c,
199 Err(resp) => return resp.into_response(),
200 };
201
202 match coll.add_edge(edge) {
205 Ok(()) => StatusCode::CREATED.into_response(),
206 Err(e) => auto_core_error_response(&e),
207 }
208}
209
210#[allow(clippy::result_large_err)]
214fn build_edge(request: AddEdgeRequest) -> Result<GraphEdge, (StatusCode, Json<ErrorResponse>)> {
215 let properties: std::collections::HashMap<String, serde_json::Value> = match request.properties
216 {
217 serde_json::Value::Object(map) => map.into_iter().collect(),
218 serde_json::Value::Null => std::collections::HashMap::new(),
219 _ => {
220 return Err((
221 StatusCode::BAD_REQUEST,
222 Json(ErrorResponse {
223 error: "Properties must be an object or null".to_string(),
224 code: None,
225 }),
226 ));
227 }
228 };
229
230 let edge = GraphEdge::new(request.id, request.source, request.target, &request.label)
231 .map_err(|e| {
232 (
233 StatusCode::BAD_REQUEST,
234 Json(ErrorResponse {
235 error: format!("Invalid edge: {e}"),
236 code: None,
237 }),
238 )
239 })?
240 .with_properties(properties);
241 Ok(edge)
242}
243
244#[utoipa::path(
246 post,
247 path = "/collections/{name}/graph/edges/batch",
248 request_body = AddEdgesBatchRequest,
249 responses(
250 (status = 201, description = "Edges added successfully", body = AddEdgesBatchResponse),
251 (status = 400, description = "Invalid request", body = ErrorResponse),
252 (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),
253 (status = 500, description = "Internal server error", body = ErrorResponse)
254 ),
255 tag = "graph"
256)]
257pub async fn add_edges_batch(
258 Path(name): Path<String>,
259 State(state): State<Arc<AppState>>,
260 Json(request): Json<AddEdgesBatchRequest>,
261) -> axum::response::Response {
262 let edges = match request
263 .edges
264 .into_iter()
265 .map(build_edge)
266 .collect::<Result<Vec<_>, _>>()
267 {
268 Ok(edges) => edges,
269 Err(resp) => return resp.into_response(),
270 };
271
272 let coll = match graph_preamble(&state, &name) {
273 Ok(c) => c,
274 Err(resp) => return resp.into_response(),
275 };
276
277 match coll.add_edges_batch(edges) {
280 Ok(added) => (StatusCode::CREATED, Json(AddEdgesBatchResponse { added })).into_response(),
281 Err(e) => auto_core_error_response(&e),
282 }
283}
284
285#[utoipa::path(
287 post,
288 path = "/collections/{name}/graph/traverse",
289 request_body = TraverseRequest,
290 responses(
291 (status = 200, description = "Traversal completed successfully", body = TraverseResponse),
292 (status = 400, description = "Invalid request", body = ErrorResponse),
293 (status = 404, description = "Collection not found", body = ErrorResponse),
294 (status = 500, description = "Internal server error", body = ErrorResponse)
295 ),
296 tag = "graph"
297)]
298pub async fn traverse_graph(
299 Path(name): Path<String>,
300 State(state): State<Arc<AppState>>,
301 Json(request): Json<TraverseRequest>,
302) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
303 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
304
305 let config = TraversalConfig::with_range(1, request.max_depth)
306 .with_limit(request.limit)
307 .with_rel_types(request.rel_types);
308
309 let raw_results = match request.strategy.to_lowercase().as_str() {
310 "bfs" => coll.traverse_bfs(request.source, &config),
311 "dfs" => coll.traverse_dfs(request.source, &config),
312 _ => {
313 return Err((
314 StatusCode::BAD_REQUEST,
315 Json(ErrorResponse {
316 error: format!(
317 "Invalid strategy '{}'. Use 'bfs' or 'dfs'.",
318 request.strategy
319 ),
320 code: None,
321 }),
322 ));
323 }
324 };
325
326 let results: Vec<super::types::TraversalResultItem> = raw_results
327 .into_iter()
328 .map(|r| super::types::TraversalResultItem {
329 target_id: r.target_id,
330 depth: r.depth,
331 path: r.path,
332 })
333 .collect();
334
335 let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
336 let visited = results.len();
337 let has_more = visited >= request.limit;
338
339 Ok(Json(TraverseResponse {
340 results,
341 has_more,
342 stats: TraversalStats {
343 visited,
344 depth_reached,
345 },
346 }))
347}
348
349#[utoipa::path(
351 get,
352 path = "/collections/{name}/graph/nodes/{node_id}/degree",
353 params(
354 ("name" = String, Path, description = "Collection name"),
355 ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
356 ),
357 responses(
358 (status = 200, description = "Degree retrieved successfully", body = DegreeResponse),
359 (status = 404, description = "Collection not found", body = ErrorResponse),
360 (status = 500, description = "Internal server error", body = ErrorResponse)
361 ),
362 tag = "graph"
363)]
364pub async fn get_node_degree(
365 Path((name, node_id)): Path<(String, u64)>,
366 State(state): State<Arc<AppState>>,
367) -> Result<Json<DegreeResponse>, (StatusCode, Json<ErrorResponse>)> {
368 let coll = graph_read_preamble(&state, &name, QueryOperationKind::GraphTraversal)?;
369 let (in_degree, out_degree) = coll.node_degree(node_id);
370 Ok(Json(DegreeResponse {
371 in_degree,
372 out_degree,
373 }))
374}