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, DegreeResponse, EdgeQueryParams, EdgeResponse, EdgesResponse, TraversalStats,
22 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 properties: std::collections::HashMap<String, serde_json::Value> = match request.properties
161 {
162 serde_json::Value::Object(map) => map.into_iter().collect(),
163 serde_json::Value::Null => std::collections::HashMap::new(),
164 _ => {
165 return Err((
166 StatusCode::BAD_REQUEST,
167 Json(ErrorResponse {
168 error: "Properties must be an object or null".to_string(),
169 code: None,
170 }),
171 ));
172 }
173 };
174
175 let edge = GraphEdge::new(request.id, request.source, request.target, &request.label)
176 .map_err(|e| {
177 (
178 StatusCode::BAD_REQUEST,
179 Json(ErrorResponse {
180 error: format!("Invalid edge: {e}"),
181 code: None,
182 }),
183 )
184 })?
185 .with_properties(properties);
186
187 let coll = graph_preamble(&state, &name)?;
188
189 coll.add_edge(edge).map_err(|e| {
190 (
191 StatusCode::INTERNAL_SERVER_ERROR,
192 Json(ErrorResponse {
193 error: format!("Failed to add edge: {e}"),
194 code: None,
195 }),
196 )
197 })?;
198
199 Ok(StatusCode::CREATED)
200}
201
202#[utoipa::path(
204 post,
205 path = "/collections/{name}/graph/traverse",
206 request_body = TraverseRequest,
207 responses(
208 (status = 200, description = "Traversal completed successfully", body = TraverseResponse),
209 (status = 400, description = "Invalid request", body = ErrorResponse),
210 (status = 404, description = "Collection not found", body = ErrorResponse),
211 (status = 500, description = "Internal server error", body = ErrorResponse)
212 ),
213 tag = "graph"
214)]
215pub async fn traverse_graph(
216 Path(name): Path<String>,
217 State(state): State<Arc<AppState>>,
218 Json(request): Json<TraverseRequest>,
219) -> Result<Json<TraverseResponse>, (StatusCode, Json<ErrorResponse>)> {
220 let coll = graph_preamble(&state, &name)?;
221
222 let config = TraversalConfig::with_range(1, request.max_depth)
223 .with_limit(request.limit)
224 .with_rel_types(request.rel_types);
225
226 let raw_results = match request.strategy.to_lowercase().as_str() {
227 "bfs" => coll.traverse_bfs(request.source, &config),
228 "dfs" => coll.traverse_dfs(request.source, &config),
229 _ => {
230 return Err((
231 StatusCode::BAD_REQUEST,
232 Json(ErrorResponse {
233 error: format!(
234 "Invalid strategy '{}'. Use 'bfs' or 'dfs'.",
235 request.strategy
236 ),
237 code: None,
238 }),
239 ));
240 }
241 };
242
243 let results: Vec<super::types::TraversalResultItem> = raw_results
244 .into_iter()
245 .map(|r| super::types::TraversalResultItem {
246 target_id: r.target_id,
247 depth: r.depth,
248 path: r.path,
249 })
250 .collect();
251
252 let depth_reached = results.iter().map(|r| r.depth).max().unwrap_or(0);
253 let visited = results.len();
254 let has_more = visited >= request.limit;
255
256 Ok(Json(TraverseResponse {
257 results,
258 has_more,
259 stats: TraversalStats {
260 visited,
261 depth_reached,
262 },
263 }))
264}
265
266#[utoipa::path(
268 get,
269 path = "/collections/{name}/graph/nodes/{node_id}/degree",
270 params(
271 ("name" = String, Path, description = "Collection name"),
272 ("node_id" = u64, Path, description = "Node ID")
273 ),
274 responses(
275 (status = 200, description = "Degree retrieved successfully", body = DegreeResponse),
276 (status = 404, description = "Collection not found", body = ErrorResponse),
277 (status = 500, description = "Internal server error", body = ErrorResponse)
278 ),
279 tag = "graph"
280)]
281pub async fn get_node_degree(
282 Path((name, node_id)): Path<(String, u64)>,
283 State(state): State<Arc<AppState>>,
284) -> Result<Json<DegreeResponse>, (StatusCode, Json<ErrorResponse>)> {
285 let coll = graph_preamble(&state, &name)?;
286 let (in_degree, out_degree) = coll.node_degree(node_id);
287 Ok(Json(DegreeResponse {
288 in_degree,
289 out_degree,
290 }))
291}