Skip to main content

linkrs_server/http/handlers/schema/
version.rs

1//! Schema version inspection handlers.
2//!
3//! Covers version history, change ranges and breaking change detection.
4
5use axum::{
6    extract::{Path, State},
7    response::Json as JsonResponse,
8};
9use tokio::task;
10
11use crate::http::{error::HttpError, state::AppState};
12use crate::storage::{
13    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
14};
15
16// ==================== Schema Versioning ====================
17
18use serde::Serialize;
19
20#[derive(Debug, Serialize, utoipa::ToSchema)]
21pub struct ChangeInfo {
22    pub change_type: String,
23    pub description: String,
24    pub details: std::collections::HashMap<String, String>,
25}
26
27/// Parse is_edge query parameter, failing on invalid values
28pub(crate) fn parse_is_edge_param(
29    query: &std::collections::HashMap<String, String>,
30) -> Result<bool, HttpError> {
31    match query.get("is_edge") {
32        None => Ok(false),
33        Some(v) => match v.to_lowercase().as_str() {
34            "true" => Ok(true),
35            "false" => Ok(false),
36            _ => Err(HttpError::BadRequest(format!(
37                "Invalid is_edge value: '{}'. Expected 'true' or 'false'",
38                v
39            ))),
40        },
41    }
42}
43
44#[utoipa::path(
45    get,
46    path = "/v1/schema/versions/{space}/{label}",
47    tag = "Schema",
48    params(
49        ("space" = String, Path, description = "Space name"),
50        ("label" = String, Path, description = "Tag or edge type name"),
51        ("is_edge" = Option<bool>, Query, description = "Whether the label is an edge type")
52    ),
53    responses(
54        (status = 200, body = serde_json::Value, description = "Schema version history"),
55        (status = 500, description = "Internal error")
56    )
57)]
58/// Get version history for a label (vertex tag or edge type)
59pub async fn get_version_history<
60    S: StorageClient
61        + StorageSchemaContextOps
62        + StorageSyncContextOps
63        + StorageOperationContextOps
64        + Clone
65        + Send
66        + Sync
67        + 'static,
68>(
69    State(state): State<AppState<S>>,
70    Path((space, label)): Path<(String, String)>,
71    axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
72) -> Result<JsonResponse<serde_json::Value>, HttpError> {
73    let is_edge = parse_is_edge_param(&query)?;
74
75    let result = task::spawn_blocking(move || {
76        let storage = state.server.get_storage();
77        let storage_read = storage.read();
78
79        let history = if is_edge {
80            storage_read.get_edge_version_history(&space, &label)
81        } else {
82            storage_read.get_vertex_version_history(&space, &label)
83        }
84        .map_err(|e| HttpError::InternalError(format!("Failed to get version history: {}", e)))?;
85
86        let versions = history
87            .map(|h| {
88                h.change_log
89                    .get_versions()
90                    .iter()
91                    .map(|&version| {
92                        let version_changes = h
93                            .change_log
94                            .get_version_changes(version)
95                            .cloned()
96                            .unwrap_or_default();
97                        let timestamp_ms = version_changes
98                            .iter()
99                            .map(|c| c.timestamp_ms)
100                            .max()
101                            .unwrap_or(0);
102                        let changes: Vec<_> = version_changes
103                            .into_iter()
104                            .map(|change| ChangeInfo {
105                                change_type: format!("{:?}", change.details),
106                                description: change.details.description(),
107                                details: {
108                                    let mut d = std::collections::HashMap::new();
109                                    d.insert(
110                                        "description".to_string(),
111                                        change.details.description(),
112                                    );
113                                    d
114                                },
115                            })
116                            .collect();
117
118                        serde_json::json!({
119                            "version": version,
120                            "timestamp_ms": timestamp_ms,
121                            "changes": changes,
122                        })
123                    })
124                    .collect::<Vec<_>>()
125            })
126            .unwrap_or_default();
127
128        Ok::<_, HttpError>(serde_json::json!({
129            "space": space,
130            "label": label,
131            "is_edge": is_edge,
132            "versions": versions,
133        }))
134    })
135    .await
136    .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
137
138    Ok(JsonResponse(result?))
139}
140
141#[utoipa::path(
142    get,
143    path = "/v1/schema/changes/{space}/{label}/{from_version}/{to_version}",
144    tag = "Schema",
145    params(
146        ("space" = String, Path, description = "Space name"),
147        ("label" = String, Path, description = "Tag or edge type name"),
148        ("from_version" = u64, Path, description = "Start schema version"),
149        ("to_version" = u64, Path, description = "End schema version"),
150        ("is_edge" = Option<bool>, Query, description = "Whether the label is an edge type")
151    ),
152    responses(
153        (status = 200, body = serde_json::Value, description = "Schema changes between versions"),
154        (status = 500, description = "Internal error")
155    )
156)]
157/// Get schema changes between two versions
158pub async fn get_schema_changes<
159    S: StorageClient
160        + StorageSchemaContextOps
161        + StorageSyncContextOps
162        + StorageOperationContextOps
163        + Clone
164        + Send
165        + Sync
166        + 'static,
167>(
168    State(state): State<AppState<S>>,
169    Path((space, label, from_version, to_version)): Path<(String, String, u64, u64)>,
170    axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
171) -> Result<JsonResponse<serde_json::Value>, HttpError> {
172    let is_edge = parse_is_edge_param(&query)?;
173
174    // Validate version range: from_version must be <= to_version
175    if from_version > to_version {
176        return Err(HttpError::BadRequest(format!(
177            "Invalid version range: from_version ({}) must be <= to_version ({})",
178            from_version, to_version
179        )));
180    }
181
182    let result = task::spawn_blocking(move || {
183        let storage = state.server.get_storage();
184        let storage_read = storage.read();
185
186        let changes = if is_edge {
187            storage_read.get_edge_schema_changes(&space, &label, from_version, to_version)
188        } else {
189            storage_read.get_vertex_schema_changes(&space, &label, from_version, to_version)
190        }
191        .map_err(|e| HttpError::InternalError(format!("Failed to get schema changes: {}", e)))?;
192
193        let change_list: Vec<_> = changes
194            .iter()
195            .map(|change| {
196                let mut details_map: std::collections::HashMap<&str, String> =
197                    std::collections::HashMap::new();
198                details_map.insert("description".into(), change.details.description());
199                serde_json::json!({
200                    "change_type": format!("{:?}", change.details),
201                    "description": change.details.description(),
202                    "details": details_map,
203                })
204            })
205            .collect();
206
207        Ok::<_, HttpError>(serde_json::json!({
208            "space": space,
209            "label": label,
210            "is_edge": is_edge,
211            "from_version": from_version,
212            "to_version": to_version,
213            "changes": change_list,
214        }))
215    })
216    .await
217    .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
218
219    Ok(JsonResponse(result?))
220}
221
222#[utoipa::path(
223    get,
224    path = "/v1/schema/breaking-changes/{space}/{label}/{from_version}/{to_version}",
225    tag = "Schema",
226    params(
227        ("space" = String, Path, description = "Space name"),
228        ("label" = String, Path, description = "Tag or edge type name"),
229        ("from_version" = u64, Path, description = "Start schema version"),
230        ("to_version" = u64, Path, description = "End schema version"),
231        ("is_edge" = Option<bool>, Query, description = "Whether the label is an edge type")
232    ),
233    responses(
234        (status = 200, body = serde_json::Value, description = "Breaking change report"),
235        (status = 500, description = "Internal error")
236    )
237)]
238/// Detect breaking changes between two versions
239pub async fn detect_breaking_changes<
240    S: StorageClient
241        + StorageSchemaContextOps
242        + StorageSyncContextOps
243        + StorageOperationContextOps
244        + Clone
245        + Send
246        + Sync
247        + 'static,
248>(
249    State(state): State<AppState<S>>,
250    Path((space, label, from_version, to_version)): Path<(String, String, u64, u64)>,
251    axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
252) -> Result<JsonResponse<serde_json::Value>, HttpError> {
253    let is_edge = parse_is_edge_param(&query)?;
254
255    // Validate version range: from_version must be <= to_version
256    if from_version > to_version {
257        return Err(HttpError::BadRequest(format!(
258            "Invalid version range: from_version ({}) must be <= to_version ({})",
259            from_version, to_version
260        )));
261    }
262
263    let result = task::spawn_blocking(move || {
264        let storage = state.server.get_storage();
265        let storage_read = storage.read();
266
267        let changes = if is_edge {
268            storage_read.detect_edge_breaking_changes(&space, &label, from_version, to_version)
269        } else {
270            storage_read.detect_vertex_breaking_changes(&space, &label, from_version, to_version)
271        }
272        .map_err(|e| {
273            HttpError::InternalError(format!("Failed to detect breaking changes: {}", e))
274        })?;
275
276        let has_breaking = !changes.is_empty();
277        let change_list: Vec<_> = changes
278            .iter()
279            .map(|change| {
280                let mut details_map: std::collections::HashMap<&str, String> =
281                    std::collections::HashMap::new();
282                details_map.insert("description".into(), change.details.description());
283                serde_json::json!({
284                    "change_type": format!("{:?}", change.details),
285                    "description": change.details.description(),
286                    "details": details_map,
287                })
288            })
289            .collect();
290
291        let recommendation = if has_breaking {
292            format!(
293                "Found {} breaking changes. Data migration may be required.",
294                change_list.len()
295            )
296        } else {
297            "No breaking changes detected".to_string()
298        };
299
300        Ok::<_, HttpError>(serde_json::json!({
301            "space": space,
302            "label": label,
303            "is_edge": is_edge,
304            "from_version": from_version,
305            "to_version": to_version,
306            "has_breaking_changes": has_breaking,
307            "changes": change_list,
308            "recommendation": recommendation,
309        }))
310    })
311    .await
312    .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
313
314    Ok(JsonResponse(result?))
315}