Skip to main content

linkrs_server/web/handlers/
graph_data.rs

1//! Graph Data Handlers
2//!
3//! Provides graph data query APIs for visualization:
4//! - Vertex details
5//! - Edge details
6//! - Neighbor queries
7
8use axum::{
9    extract::{Extension, Path, Query, State},
10    response::Json,
11    routing::get,
12    Router,
13};
14use serde::Deserialize;
15
16use crate::storage::{
17    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
18};
19use crate::web::{
20    error::{WebError, WebResult},
21    handlers::common::{execute_in_space, vid_literal},
22    models::ApiResponse,
23    WebState,
24};
25
26/// Create graph data routes (without state)
27pub fn create_routes<
28    S: StorageClient
29        + StorageSchemaContextOps
30        + StorageSyncContextOps
31        + StorageOperationContextOps
32        + Clone
33        + Send
34        + Sync
35        + 'static,
36>() -> Router<WebState<S>> {
37    Router::new()
38        .route("/vertices/{vid}", get(get_vertex))
39        .route("/edges", get(get_edge))
40        .route("/vertices/{vid}/neighbors", get(get_neighbors))
41}
42
43/// Get vertex details
44#[derive(Debug, Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
45#[into_params(parameter_in = Query)]
46pub struct GetVertexParams {
47    pub space: String,
48}
49
50#[utoipa::path(
51    get,
52    path = "/api/v1/graph/vertices/{vid}",
53    tag = "WebGraph",
54    params(
55        ("vid" = String, Path, description = "Vertex id"),
56        GetVertexParams
57    ),
58    responses(
59        (status = 200, description = "Vertex detail", body = ApiResponse<serde_json::Value>),
60        (status = 404, description = "Not found"),
61        (status = 500, description = "Internal error")
62    )
63)]
64async fn get_vertex<
65    S: StorageClient
66        + StorageSchemaContextOps
67        + StorageSyncContextOps
68        + StorageOperationContextOps
69        + Clone
70        + Send
71        + Sync
72        + 'static,
73>(
74    State(web_state): State<WebState<S>>,
75    Extension(caller_session_id): Extension<i64>,
76    Path(vid): Path<String>,
77    Query(params): Query<GetVertexParams>,
78) -> WebResult<Json<ApiResponse<serde_json::Value>>> {
79    // A vertex detail request has no tag qualifier, and the FETCH planner
80    // requires one; an unlabeled MATCH scans every tag in the space.
81    let id = vid_literal(&web_state, &params.space, &vid)?;
82    let query = format!("MATCH (v) WHERE id(v) == {} RETURN v LIMIT 1", id);
83
84    let result = execute_in_space(&web_state, caller_session_id, &params.space, &query).await?;
85    if let Some(vertex) = result.first_value() {
86        return Ok(Json(ApiResponse::success(
87            serde_json::json!({"vertex": vertex}),
88        )));
89    }
90
91    Err(WebError::NotFound(format!(
92        "Vertex '{}' not found in space '{}'",
93        vid, params.space
94    )))
95}
96
97/// Get edge details
98#[derive(Debug, Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
99#[into_params(parameter_in = Query)]
100pub struct GetEdgeParams {
101    pub space: String,
102    pub src: String,
103    pub dst: String,
104    pub edge_type: String,
105    #[serde(default)]
106    pub rank: i64,
107}
108
109#[utoipa::path(
110    get,
111    path = "/api/v1/graph/edges",
112    tag = "WebGraph",
113    params(GetEdgeParams),
114    responses(
115        (status = 200, description = "Edge detail", body = ApiResponse<serde_json::Value>),
116        (status = 404, description = "Not found"),
117        (status = 500, description = "Internal error")
118    )
119)]
120async fn get_edge<
121    S: StorageClient
122        + StorageSchemaContextOps
123        + StorageSyncContextOps
124        + StorageOperationContextOps
125        + Clone
126        + Send
127        + Sync
128        + 'static,
129>(
130    State(web_state): State<WebState<S>>,
131    Extension(caller_session_id): Extension<i64>,
132    Query(params): Query<GetEdgeParams>,
133) -> WebResult<Json<ApiResponse<serde_json::Value>>> {
134    // Build query to fetch edge
135    let query = format!(
136        "FETCH PROP ON {} \"{}\" -> \"{}\"@{} YIELD edge AS e",
137        params.edge_type, params.src, params.dst, params.rank
138    );
139
140    let result = execute_in_space(&web_state, caller_session_id, &params.space, &query).await?;
141    if let Some(edge) = result.first_value() {
142        return Ok(Json(ApiResponse::success(
143            serde_json::json!({"edge": edge}),
144        )));
145    }
146
147    Err(WebError::NotFound(format!(
148        "Edge from '{}' to '{}' with type '{}' not found in space '{}'",
149        params.src, params.dst, params.edge_type, params.space
150    )))
151}
152
153/// Get neighbors of a vertex
154#[derive(Debug, Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
155#[into_params(parameter_in = Query)]
156pub struct GetNeighborsParams {
157    pub space: String,
158    /// Direction: OUT, IN, or BOTH
159    #[serde(default = "default_direction")]
160    pub direction: String,
161    /// Edge type filter
162    pub edge_type: Option<String>,
163}
164
165fn default_direction() -> String {
166    "BOTH".to_string()
167}
168
169#[utoipa::path(
170    get,
171    path = "/api/v1/graph/vertices/{vid}/neighbors",
172    tag = "WebGraph",
173    params(
174        ("vid" = String, Path, description = "Vertex id"),
175        GetNeighborsParams
176    ),
177    responses(
178        (status = 200, description = "Neighbor list", body = ApiResponse<serde_json::Value>),
179        (status = 500, description = "Internal error")
180    )
181)]
182async fn get_neighbors<
183    S: StorageClient
184        + StorageSchemaContextOps
185        + StorageSyncContextOps
186        + StorageOperationContextOps
187        + Clone
188        + Send
189        + Sync
190        + 'static,
191>(
192    State(web_state): State<WebState<S>>,
193    Extension(caller_session_id): Extension<i64>,
194    Path(vid): Path<String>,
195    Query(params): Query<GetNeighborsParams>,
196) -> WebResult<Json<ApiResponse<serde_json::Value>>> {
197    // An edge token carries the edge type after a colon; a bare name is read as
198    // the type itself. With no filter the bracket pair matches any type.
199    let edge = match &params.edge_type {
200        Some(edge_type) => format!("[:{}]", edge_type),
201        None => "[]".to_string(),
202    };
203    let pattern = match params.direction.as_str() {
204        "OUT" => format!("(v)-{}->(n)", edge),
205        "IN" => format!("(v)<-{}-(n)", edge),
206        _ => format!("(v)-{}-(n)", edge),
207    };
208
209    let id = vid_literal(&web_state, &params.space, &vid)?;
210    let query = format!("MATCH {} WHERE id(v) == {} RETURN n LIMIT 100", pattern, id);
211
212    let result = execute_in_space(&web_state, caller_session_id, &params.space, &query).await?;
213    let neighbors: Vec<serde_json::Value> = result
214        .rows()
215        .iter()
216        .flat_map(|row| row.iter())
217        .filter_map(|v| match v {
218            linkrs_core::Value::Vertex(vertex) => Some(serde_json::json!({"vertex": vertex})),
219            _ => None,
220        })
221        .collect();
222
223    Ok(Json(ApiResponse::success(serde_json::json!({
224        "vid": vid,
225        "space": params.space,
226        "direction": params.direction,
227        "edge_type": params.edge_type,
228        "neighbors": neighbors,
229    }))))
230}