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