Skip to main content

fraiseql_server/routes/api/
design.rs

1//! Design Quality Audit API Endpoints
2//!
3//! Provides FraiseQL-calibrated design quality analysis for schemas.
4
5use axum::{Json, extract::State};
6use fraiseql_core::{
7    db::traits::DatabaseAdapter,
8    design::{DesignAudit, IssueSeverity},
9};
10use serde::{Deserialize, Serialize};
11
12use crate::routes::{
13    api::types::{ApiError, ApiResponse},
14    graphql::AppState,
15};
16
17/// Request body for design audit endpoints
18#[derive(Debug, Clone, Deserialize)]
19pub struct DesignAuditRequest {
20    /// Schema to analyze (JSON)
21    pub schema: serde_json::Value,
22}
23
24/// Single design issue response
25#[derive(Debug, Clone, Serialize)]
26pub struct DesignIssueResponse {
27    /// Severity: critical, warning, info
28    pub severity:   String,
29    /// Human-readable message
30    pub message:    String,
31    /// Actionable suggestion
32    pub suggestion: String,
33    /// Affected entity/field if applicable
34    pub affected:   Option<String>,
35}
36
37/// Category audit response with score and issues
38#[derive(Debug, Clone, Serialize)]
39pub struct CategoryAuditResponse {
40    /// Category score (0-100)
41    pub score:  u8,
42    /// Issues found in this category
43    pub issues: Vec<DesignIssueResponse>,
44}
45
46/// Severity counts
47#[derive(Debug, Clone, Serialize)]
48pub struct SeverityCountResponse {
49    /// Critical issues count
50    pub critical: usize,
51    /// Warning issues count
52    pub warning:  usize,
53    /// Info issues count
54    pub info:     usize,
55}
56
57/// Complete design audit response
58#[derive(Debug, Clone, Serialize)]
59pub struct DesignAuditResponse {
60    /// Overall design score (0-100)
61    pub overall_score:   u8,
62    /// Issue counts by severity
63    pub severity_counts: SeverityCountResponse,
64    /// Federation analysis (JSONB batching)
65    pub federation:      CategoryAuditResponse,
66    /// Cost analysis (compiled determinism)
67    pub cost:            CategoryAuditResponse,
68    /// Cache analysis (JSONB coherency)
69    pub cache:           CategoryAuditResponse,
70    /// Authorization analysis
71    pub authorization:   CategoryAuditResponse,
72    /// Compilation analysis
73    pub compilation:     CategoryAuditResponse,
74}
75
76/// Federation audit endpoint - JSONB batching analysis
77///
78/// # Errors
79///
80/// Returns `ApiError` with a parse error if the schema JSON is invalid.
81pub async fn federation_audit_handler<A: DatabaseAdapter>(
82    State(_state): State<AppState<A>>,
83    Json(req): Json<DesignAuditRequest>,
84) -> std::result::Result<Json<ApiResponse<CategoryAuditResponse>>, ApiError> {
85    let audit = DesignAudit::from_schema_json(&req.schema.to_string())
86        .map_err(|e| ApiError::parse_error(format!("Invalid schema: {}", e)))?;
87
88    let issues: Vec<DesignIssueResponse> = audit
89        .federation_issues
90        .iter()
91        .map(|issue| DesignIssueResponse {
92            severity:   format!("{:?}", issue.severity).to_lowercase(),
93            message:    issue.message.clone(),
94            suggestion: issue.suggestion.clone(),
95            affected:   issue.entity.clone(),
96        })
97        .collect();
98
99    let score = if issues.is_empty() {
100        100
101    } else {
102        let count = u32::try_from(issues.len()).unwrap_or(u32::MAX);
103        (100u32 - (count * 10)).clamp(0, 100) as u8
104    };
105
106    Ok(Json(ApiResponse {
107        status: "success".to_string(),
108        data:   CategoryAuditResponse { score, issues },
109    }))
110}
111
112/// Cost audit endpoint - Compiled query determinism analysis
113///
114/// # Errors
115///
116/// Returns `ApiError` with a parse error if the schema JSON is invalid.
117pub async fn cost_audit_handler<A: DatabaseAdapter>(
118    State(_state): State<AppState<A>>,
119    Json(req): Json<DesignAuditRequest>,
120) -> std::result::Result<Json<ApiResponse<CategoryAuditResponse>>, ApiError> {
121    let audit = DesignAudit::from_schema_json(&req.schema.to_string())
122        .map_err(|e| ApiError::parse_error(format!("Invalid schema: {}", e)))?;
123
124    let issues: Vec<DesignIssueResponse> = audit
125        .cost_warnings
126        .iter()
127        .map(|warning| DesignIssueResponse {
128            severity:   format!("{:?}", warning.severity).to_lowercase(),
129            message:    warning.message.clone(),
130            suggestion: warning.suggestion.clone(),
131            affected:   warning.worst_case_complexity.map(|c| format!("complexity: {}", c)),
132        })
133        .collect();
134
135    let score = if issues.is_empty() {
136        100
137    } else {
138        let count = u32::try_from(issues.len()).unwrap_or(u32::MAX);
139        (100u32 - (count * 8)).clamp(0, 100) as u8
140    };
141
142    Ok(Json(ApiResponse {
143        status: "success".to_string(),
144        data:   CategoryAuditResponse { score, issues },
145    }))
146}
147
148/// Cache audit endpoint - JSONB coherency analysis
149///
150/// # Errors
151///
152/// Returns `ApiError` with a parse error if the schema JSON is invalid.
153pub async fn cache_audit_handler<A: DatabaseAdapter>(
154    State(_state): State<AppState<A>>,
155    Json(req): Json<DesignAuditRequest>,
156) -> std::result::Result<Json<ApiResponse<CategoryAuditResponse>>, ApiError> {
157    let audit = DesignAudit::from_schema_json(&req.schema.to_string())
158        .map_err(|e| ApiError::parse_error(format!("Invalid schema: {}", e)))?;
159
160    let issues: Vec<DesignIssueResponse> = audit
161        .cache_issues
162        .iter()
163        .map(|issue| DesignIssueResponse {
164            severity:   format!("{:?}", issue.severity).to_lowercase(),
165            message:    issue.message.clone(),
166            suggestion: issue.suggestion.clone(),
167            affected:   issue.affected.clone(),
168        })
169        .collect();
170
171    let score = if issues.is_empty() {
172        100
173    } else {
174        let count = u32::try_from(issues.len()).unwrap_or(u32::MAX);
175        (100u32 - (count * 6)).clamp(0, 100) as u8
176    };
177
178    Ok(Json(ApiResponse {
179        status: "success".to_string(),
180        data:   CategoryAuditResponse { score, issues },
181    }))
182}
183
184/// Authorization audit endpoint - Auth boundary analysis
185///
186/// # Errors
187///
188/// Returns `ApiError` with a parse error if the schema JSON is invalid.
189pub async fn auth_audit_handler<A: DatabaseAdapter>(
190    State(_state): State<AppState<A>>,
191    Json(req): Json<DesignAuditRequest>,
192) -> std::result::Result<Json<ApiResponse<CategoryAuditResponse>>, ApiError> {
193    let audit = DesignAudit::from_schema_json(&req.schema.to_string())
194        .map_err(|e| ApiError::parse_error(format!("Invalid schema: {}", e)))?;
195
196    let issues: Vec<DesignIssueResponse> = audit
197        .auth_issues
198        .iter()
199        .map(|issue| DesignIssueResponse {
200            severity:   format!("{:?}", issue.severity).to_lowercase(),
201            message:    issue.message.clone(),
202            suggestion: issue.suggestion.clone(),
203            affected:   issue.affected_field.clone(),
204        })
205        .collect();
206
207    let score = if issues.is_empty() {
208        100
209    } else {
210        let count = u32::try_from(issues.len()).unwrap_or(u32::MAX);
211        (100u32 - (count * 12)).clamp(0, 100) as u8
212    };
213
214    Ok(Json(ApiResponse {
215        status: "success".to_string(),
216        data:   CategoryAuditResponse { score, issues },
217    }))
218}
219
220/// Compilation audit endpoint - Type suitability analysis
221///
222/// # Errors
223///
224/// Returns `ApiError` with a parse error if the schema JSON is invalid.
225pub async fn compilation_audit_handler<A: DatabaseAdapter>(
226    State(_state): State<AppState<A>>,
227    Json(req): Json<DesignAuditRequest>,
228) -> std::result::Result<Json<ApiResponse<CategoryAuditResponse>>, ApiError> {
229    let audit = DesignAudit::from_schema_json(&req.schema.to_string())
230        .map_err(|e| ApiError::parse_error(format!("Invalid schema: {}", e)))?;
231
232    let issues: Vec<DesignIssueResponse> = audit
233        .schema_issues
234        .iter()
235        .map(|issue| DesignIssueResponse {
236            severity:   format!("{:?}", issue.severity).to_lowercase(),
237            message:    issue.message.clone(),
238            suggestion: issue.suggestion.clone(),
239            affected:   issue.affected_type.clone(),
240        })
241        .collect();
242
243    let score = if issues.is_empty() {
244        100
245    } else {
246        let count = u32::try_from(issues.len()).unwrap_or(u32::MAX);
247        (100u32 - (count * 10)).clamp(0, 100) as u8
248    };
249
250    Ok(Json(ApiResponse {
251        status: "success".to_string(),
252        data:   CategoryAuditResponse { score, issues },
253    }))
254}
255
256/// Overall design audit endpoint
257///
258/// # Errors
259///
260/// Returns `ApiError` with a parse error if the schema JSON is invalid.
261pub async fn overall_design_audit_handler<A: DatabaseAdapter>(
262    State(_state): State<AppState<A>>,
263    Json(req): Json<DesignAuditRequest>,
264) -> std::result::Result<Json<ApiResponse<DesignAuditResponse>>, ApiError> {
265    let audit = DesignAudit::from_schema_json(&req.schema.to_string())
266        .map_err(|e| ApiError::parse_error(format!("Invalid schema: {}", e)))?;
267
268    // Convert federation issues
269    let federation_issues: Vec<DesignIssueResponse> = audit
270        .federation_issues
271        .iter()
272        .map(|issue| DesignIssueResponse {
273            severity:   format!("{:?}", issue.severity).to_lowercase(),
274            message:    issue.message.clone(),
275            suggestion: issue.suggestion.clone(),
276            affected:   issue.entity.clone(),
277        })
278        .collect();
279
280    // Convert cost warnings
281    let cost_issues: Vec<DesignIssueResponse> = audit
282        .cost_warnings
283        .iter()
284        .map(|warning| DesignIssueResponse {
285            severity:   format!("{:?}", warning.severity).to_lowercase(),
286            message:    warning.message.clone(),
287            suggestion: warning.suggestion.clone(),
288            affected:   warning.worst_case_complexity.map(|c| format!("complexity: {}", c)),
289        })
290        .collect();
291
292    // Convert cache issues
293    let cache_issues: Vec<DesignIssueResponse> = audit
294        .cache_issues
295        .iter()
296        .map(|issue| DesignIssueResponse {
297            severity:   format!("{:?}", issue.severity).to_lowercase(),
298            message:    issue.message.clone(),
299            suggestion: issue.suggestion.clone(),
300            affected:   issue.affected.clone(),
301        })
302        .collect();
303
304    // Convert auth issues
305    let auth_issues: Vec<DesignIssueResponse> = audit
306        .auth_issues
307        .iter()
308        .map(|issue| DesignIssueResponse {
309            severity:   format!("{:?}", issue.severity).to_lowercase(),
310            message:    issue.message.clone(),
311            suggestion: issue.suggestion.clone(),
312            affected:   issue.affected_field.clone(),
313        })
314        .collect();
315
316    // Convert compilation issues
317    let compilation_issues: Vec<DesignIssueResponse> = audit
318        .schema_issues
319        .iter()
320        .map(|issue| DesignIssueResponse {
321            severity:   format!("{:?}", issue.severity).to_lowercase(),
322            message:    issue.message.clone(),
323            suggestion: issue.suggestion.clone(),
324            affected:   issue.affected_type.clone(),
325        })
326        .collect();
327
328    let severity_counts = SeverityCountResponse {
329        critical: audit.severity_count(IssueSeverity::Critical),
330        warning:  audit.severity_count(IssueSeverity::Warning),
331        info:     audit.severity_count(IssueSeverity::Info),
332    };
333
334    let fed_score = if federation_issues.is_empty() {
335        100
336    } else {
337        let count = u32::try_from(federation_issues.len()).unwrap_or(u32::MAX);
338        (100u32 - (count * 10)).clamp(0, 100) as u8
339    };
340
341    let cost_score = if cost_issues.is_empty() {
342        100
343    } else {
344        let count = u32::try_from(cost_issues.len()).unwrap_or(u32::MAX);
345        (100u32 - (count * 8)).clamp(0, 100) as u8
346    };
347
348    let cache_score = if cache_issues.is_empty() {
349        100
350    } else {
351        let count = u32::try_from(cache_issues.len()).unwrap_or(u32::MAX);
352        (100u32 - (count * 6)).clamp(0, 100) as u8
353    };
354
355    let auth_score = if auth_issues.is_empty() {
356        100
357    } else {
358        let count = u32::try_from(auth_issues.len()).unwrap_or(u32::MAX);
359        (100u32 - (count * 12)).clamp(0, 100) as u8
360    };
361
362    let comp_score = if compilation_issues.is_empty() {
363        100
364    } else {
365        let count = u32::try_from(compilation_issues.len()).unwrap_or(u32::MAX);
366        (100u32 - (count * 10)).clamp(0, 100) as u8
367    };
368
369    let response = DesignAuditResponse {
370        overall_score: audit.score(),
371        severity_counts,
372        federation: CategoryAuditResponse {
373            score:  fed_score,
374            issues: federation_issues,
375        },
376        cost: CategoryAuditResponse {
377            score:  cost_score,
378            issues: cost_issues,
379        },
380        cache: CategoryAuditResponse {
381            score:  cache_score,
382            issues: cache_issues,
383        },
384        authorization: CategoryAuditResponse {
385            score:  auth_score,
386            issues: auth_issues,
387        },
388        compilation: CategoryAuditResponse {
389            score:  comp_score,
390            issues: compilation_issues,
391        },
392    };
393
394    Ok(Json(ApiResponse {
395        status: "success".to_string(),
396        data:   response,
397    }))
398}
399
400#[cfg(test)]
401mod tests {
402    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
403    #![allow(clippy::cast_precision_loss)] // Reason: test metrics reporting
404    #![allow(clippy::cast_sign_loss)] // Reason: test data uses small positive integers
405    #![allow(clippy::cast_possible_truncation)] // Reason: test data values are bounded
406    #![allow(clippy::cast_possible_wrap)] // Reason: test data values are bounded
407    #![allow(clippy::missing_panics_doc)] // Reason: test helpers
408    #![allow(clippy::missing_errors_doc)] // Reason: test helpers
409    #![allow(missing_docs)] // Reason: test code
410    #![allow(clippy::items_after_statements)] // Reason: test helpers defined near use site
411
412    use super::*;
413
414    #[test]
415    fn test_severity_count_response() {
416        let resp = SeverityCountResponse {
417            critical: 1,
418            warning:  3,
419            info:     5,
420        };
421        let json = serde_json::to_string(&resp).unwrap();
422        assert!(json.contains("\"critical\":1"));
423    }
424}