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};
17
18use crate::handlers::helpers::auto_core_error_response;
19use crate::types::ErrorResponse;
20use crate::AppState;
21
22use super::types::{
23 AddEdgeRequest, AddEdgesBatchRequest, AddEdgesBatchResponse, DegreeResponse, EdgeQueryParams,
24 EdgeResponse, EdgesResponse, TraversalStats, TraverseRequest, TraverseResponse,
25};
26
27#[allow(clippy::result_large_err)]
33pub(super) fn graph_preamble(
34 state: &AppState,
35 name: &str,
36) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
37 state.onboarding_metrics.record_graph_request();
38 get_graph_collection_or_404(state, name)
39}
40
41pub(super) fn get_graph_collection_or_404(
58 state: &AppState,
59 name: &str,
60) -> Result<velesdb_core::GraphCollection, (StatusCode, Json<ErrorResponse>)> {
61 if let Some(c) = state.db.get_graph_collection(name) {
62 return Ok(c);
63 }
64
65 if state.db.get_vector_collection(name).is_some()
67 || state.db.get_metadata_collection(name).is_some()
68 {
69 return Err((
70 StatusCode::CONFLICT,
71 Json(ErrorResponse {
72 error: format!(
73 "Collection '{name}' exists but is not a graph collection. \
74 Use /collections/{name}/graph only on graph-typed collections.",
75 ),
76 code: None,
77 }),
78 ));
79 }
80
81 let err = velesdb_core::Error::CollectionNotFound(name.to_string());
86 Err((
87 StatusCode::NOT_FOUND,
88 Json(ErrorResponse {
89 error: format!(
90 "{err}. Create it first with \
91 POST /collections and collection_type = \"graph\".",
92 ),
93 code: Some(err.code().to_string()),
94 }),
95 ))
96}
97
98#[utoipa::path(
100 get,
101 path = "/collections/{name}/graph/edges",
102 params(("name" = String, Path, description = "Collection name"), EdgeQueryParams),
103 responses(
104 (status = 200, description = "Edges retrieved successfully", body = EdgesResponse),
105 (status = 400, description = "Missing required 'label' query parameter", body = ErrorResponse),
106 (status = 404, description = "Collection not found", body = ErrorResponse),
107 (status = 500, description = "Internal server error", body = ErrorResponse)
108 ),
109 tag = "graph"
110)]
111pub async fn get_edges(
112 Path(name): Path<String>,
113 Query(params): Query<EdgeQueryParams>,
114 State(state): State<Arc<AppState>>,
115) -> Result<Json<EdgesResponse>, (StatusCode, Json<ErrorResponse>)> {
116 let label = params.label.ok_or_else(|| {
117 (
118 StatusCode::BAD_REQUEST,
119 Json(ErrorResponse {
120 error: "Query parameter 'label' is required. Listing all edges requires pagination (not yet implemented).".to_string(),
121 code: None,
122 }),
123 )
124 })?;
125
126 let coll = graph_preamble(&state, &name)?;
127
128 let edges: Vec<EdgeResponse> = coll
129 .get_edges(Some(&label))
130 .into_iter()
131 .map(|e| EdgeResponse {
132 id: e.id(),
133 source: e.source(),
134 target: e.target(),
135 label: e.label().to_string(),
136 properties: serde_json::to_value(e.properties()).unwrap_or_default(),
137 })
138 .collect();
139
140 let count = edges.len();
141 Ok(Json(EdgesResponse { edges, count }))
142}
143
144#[utoipa::path(
146 post,
147 path = "/collections/{name}/graph/edges",
148 request_body = AddEdgeRequest,
149 responses(
150 (status = 201, description = "Edge added successfully"),
151 (status = 400, description = "Invalid request", body = ErrorResponse),
152 (status = 404, description = "Collection not found", body = ErrorResponse),
153 (status = 500, description = "Internal server error", body = ErrorResponse)
154 ),
155 tag = "graph"
156)]
157pub async fn add_edge(
158 Path(name): Path<String>,
159 State(state): State<Arc<AppState>>,
160 Json(request): Json<AddEdgeRequest>,
161) -> axum::response::Response {
162 let edge = match build_edge(request) {
163 Ok(e) => e,
164 Err(resp) => return resp.into_response(),
165 };
166
167 let coll = match graph_preamble(&state, &name) {
168 Ok(c) => c,
169 Err(resp) => return resp.into_response(),
170 };
171
172 match coll.add_edge(edge) {
175 Ok(()) => StatusCode::CREATED.into_response(),
176 Err(e) => auto_core_error_response(&e),
177 }
178}
179
180#[allow(clippy::result_large_err)]
184fn build_edge(request: AddEdgeRequest) -> Result<GraphEdge, (StatusCode, Json<ErrorResponse>)> {
185 let properties: std::collections::HashMap<String, serde_json::Value> = match request.properties
186 {
187 serde_json::Value::Object(map) => map.into_iter().collect(),
188 serde_json::Value::Null => std::collections::HashMap::new(),
189 _ => {
190 return Err((
191 StatusCode::BAD_REQUEST,
192 Json(ErrorResponse {
193 error: "Properties must be an object or null".to_string(),
194 code: None,
195 }),
196 ));
197 }
198 };
199
200 let edge = GraphEdge::new(request.id, request.source, request.target, &request.label)
201 .map_err(|e| {
202 (
203 StatusCode::BAD_REQUEST,
204 Json(ErrorResponse {
205 error: format!("Invalid edge: {e}"),
206 code: None,
207 }),
208 )
209 })?
210 .with_properties(properties);
211 Ok(edge)
212}
213
214#[utoipa::path(
216 post,
217 path = "/collections/{name}/graph/edges/batch",
218 request_body = AddEdgesBatchRequest,
219 responses(
220 (status = 201, description = "Edges added successfully", body = AddEdgesBatchResponse),
221 (status = 400, description = "Invalid request", body = ErrorResponse),
222 (status = 404, description = "Collection not found", body = ErrorResponse),
223 (status = 500, description = "Internal server error", body = ErrorResponse)
224 ),
225 tag = "graph"
226)]
227pub async fn add_edges_batch(
228 Path(name): Path<String>,
229 State(state): State<Arc<AppState>>,
230 Json(request): Json<AddEdgesBatchRequest>,
231) -> axum::response::Response {
232 let edges = match request
233 .edges
234 .into_iter()
235 .map(build_edge)
236 .collect::<Result<Vec<_>, _>>()
237 {
238 Ok(edges) => edges,
239 Err(resp) => return resp.into_response(),
240 };
241
242 let coll = match graph_preamble(&state, &name) {
243 Ok(c) => c,
244 Err(resp) => return resp.into_response(),
245 };
246
247 match coll.add_edges_batch(edges) {
250 Ok(added) => (StatusCode::CREATED, Json(AddEdgesBatchResponse { added })).into_response(),
251 Err(e) => auto_core_error_response(&e),
252 }
253}
254
255#[utoipa::path(
257 post,
258 path = "/collections/{name}/graph/traverse",
259 request_body = TraverseRequest,
260 responses(
261 (status = 200, description = "Traversal completed successfully", body = TraverseResponse),
262 (status = 400, description = "Invalid request", body = ErrorResponse),
263 (status = 404, description = "Collection not found", body = ErrorResponse),
264 (status = 500, description = "Internal server error", body = ErrorResponse)
265 ),
266 tag = "graph"
267)]
268pub async fn traverse_graph(
269 Path(name): Path<String>,
270 State(state): State<Arc<AppState>>,
271 Json(request): Json<TraverseRequest>,
272) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
273 let coll = graph_preamble(&state, &name)?;
274
275 let config = TraversalConfig::with_range(1, request.max_depth)
276 .with_limit(request.limit)
277 .with_rel_types(request.rel_types);
278
279 let raw_results = match request.strategy.to_lowercase().as_str() {
280 "bfs" => coll.traverse_bfs(request.source, &config),
281 "dfs" => coll.traverse_dfs(request.source, &config),
282 _ => {
283 return Err((
284 StatusCode::BAD_REQUEST,
285 Json(ErrorResponse {
286 error: format!(
287 "Invalid strategy '{}'. Use 'bfs' or 'dfs'.",
288 request.strategy
289 ),
290 code: None,
291 }),
292 ));
293 }
294 };
295
296 let results: Vec<super::types::TraversalResultItem> = raw_results
297 .into_iter()
298 .map(|r| super::types::TraversalResultItem {
299 target_id: r.target_id,
300 depth: r.depth,
301 path: r.path,
302 })
303 .collect();
304
305 let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
306 let visited = results.len();
307 let has_more = visited >= request.limit;
308
309 Ok(Json(TraverseResponse {
310 results,
311 has_more,
312 stats: TraversalStats {
313 visited,
314 depth_reached,
315 },
316 }))
317}
318
319#[utoipa::path(
321 get,
322 path = "/collections/{name}/graph/nodes/{node_id}/degree",
323 params(
324 ("name" = String, Path, description = "Collection name"),
325 ("node_id" = String, Path, description = "Node ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
326 ),
327 responses(
328 (status = 200, description = "Degree retrieved successfully", body = DegreeResponse),
329 (status = 404, description = "Collection not found", body = ErrorResponse),
330 (status = 500, description = "Internal server error", body = ErrorResponse)
331 ),
332 tag = "graph"
333)]
334pub async fn get_node_degree(
335 Path((name, node_id)): Path<(String, u64)>,
336 State(state): State<Arc<AppState>>,
337) -> Result<Json<DegreeResponse>, (StatusCode, Json<ErrorResponse>)> {
338 let coll = graph_preamble(&state, &name)?;
339 let (in_degree, out_degree) = coll.node_degree(node_id);
340 Ok(Json(DegreeResponse {
341 in_degree,
342 out_degree,
343 }))
344}