Skip to main content

architect_sdk/
error.rs

1//! Typed errors and HTTP mapping.
2
3use axum::{
4    http::StatusCode,
5    response::{IntoResponse, Response},
6    Json,
7};
8use serde::Serialize;
9use thiserror::Error;
10
11#[derive(Serialize, Debug, Clone)]
12pub struct BulkFieldError {
13    pub index: usize,
14    pub field: String,
15    pub message: String,
16}
17
18#[derive(Error, Debug)]
19pub enum ConfigError {
20    #[error("missing reference: {kind} id '{id}'")]
21    MissingReference { kind: &'static str, id: String },
22    #[error("invalid primary key: table {table_id} column {column}")]
23    InvalidPrimaryKey { table_id: String, column: String },
24    #[error("duplicate path segment: {0}")]
25    DuplicatePathSegment(String),
26    #[error("config load: {0}")]
27    Load(String),
28    #[error("validation: {0}")]
29    Validation(String),
30}
31
32#[derive(Error, Debug)]
33pub enum AppError {
34    #[error(transparent)]
35    Config(#[from] ConfigError),
36    #[error("not found: {0}")]
37    NotFound(String),
38    #[error("validation: {0}")]
39    Validation(String),
40    #[error("database: {0}")]
41    Db(#[from] sqlx::Error),
42    #[error("conflict: {0}")]
43    Conflict(String),
44    #[error("bad request: {0}")]
45    BadRequest(String),
46    #[error("storage: {0}")]
47    Storage(String),
48    #[error("unauthorized: {0}")]
49    Unauthorized(String),
50    #[error("bulk validation failed")]
51    BulkValidation(Vec<BulkFieldError>),
52}
53
54#[derive(Serialize)]
55pub struct ErrorBody {
56    pub error: ErrorDetail,
57}
58
59#[derive(Serialize)]
60pub struct ErrorDetail {
61    pub code: String,
62    pub message: String,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub details: Option<serde_json::Value>,
65}
66
67/// Extract the column name from a PostgreSQL error.
68/// Extract a field name from a database constraint violation error, where available.
69/// On Postgres, this parses the `detail` field of `PgDatabaseError` (e.g. "Key (email)=... already exists").
70/// On other dialects the function returns `None` — callers fall back to generic messages.
71pub fn db_error_field(e: &AppError) -> Option<String> {
72    #[cfg(feature = "postgres")]
73    if let AppError::Db(sqlx::Error::Database(ref db_err)) = e {
74        if let Some(pg_err) = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>() {
75            if let Some(detail) = pg_err.detail() {
76                if let Some(start) = detail.find('(') {
77                    if let Some(end) = detail[start + 1..].find(')') {
78                        let field = &detail[start + 1..start + 1 + end];
79                        if !field.is_empty() && !field.contains(',') {
80                            return Some(field.trim().to_string());
81                        }
82                    }
83                }
84            }
85        }
86    }
87    #[cfg(not(feature = "postgres"))]
88    let _ = e;
89    None
90}
91
92/// Build a human-readable message for a DB error, using the extracted field name when available.
93pub fn db_error_message(e: &AppError, field: Option<&str>) -> String {
94    if let AppError::Db(sqlx::Error::Database(ref db_err)) = e {
95        match db_err.kind() {
96            sqlx::error::ErrorKind::UniqueViolation => {
97                return match field {
98                    Some(f) => format!("{} already exists", f),
99                    None => "duplicate value violates unique constraint".to_string(),
100                }
101            }
102            sqlx::error::ErrorKind::ForeignKeyViolation => {
103                return match field {
104                    Some(f) => format!("{} references a non-existent record", f),
105                    None => "foreign key constraint violation".to_string(),
106                }
107            }
108            sqlx::error::ErrorKind::NotNullViolation => {
109                return match field {
110                    Some(f) => format!("{} cannot be null", f),
111                    None => "not null constraint violation".to_string(),
112                }
113            }
114            sqlx::error::ErrorKind::CheckViolation => {
115                return "check constraint violation".to_string();
116            }
117            _ => {}
118        }
119    }
120    e.to_string()
121}
122
123impl IntoResponse for AppError {
124    fn into_response(self) -> Response {
125        if let AppError::BulkValidation(ref errors) = self {
126            let affected: std::collections::HashSet<usize> =
127                errors.iter().map(|e| e.index).collect();
128            let body = ErrorBody {
129                error: ErrorDetail {
130                    code: "bulk_validation_error".to_string(),
131                    message: format!("Validation failed for {} item(s)", affected.len()),
132                    details: Some(serde_json::to_value(errors).unwrap_or(serde_json::Value::Null)),
133                },
134            };
135            return (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response();
136        }
137        let (status, code) = match &self {
138            AppError::Config(_) => (StatusCode::INTERNAL_SERVER_ERROR, "config_error"),
139            AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
140            AppError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation_error"),
141            AppError::Db(e) => {
142                if let sqlx::Error::RowNotFound = e {
143                    (StatusCode::NOT_FOUND, "not_found")
144                } else {
145                    (StatusCode::INTERNAL_SERVER_ERROR, "database_error")
146                }
147            }
148            AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
149            AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
150            AppError::Storage(_) => (StatusCode::INTERNAL_SERVER_ERROR, "storage_error"),
151            AppError::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
152            AppError::BulkValidation(_) => unreachable!(),
153        };
154        let body = ErrorBody {
155            error: ErrorDetail {
156                code: code.to_string(),
157                message: self.to_string(),
158                details: None,
159            },
160        };
161        (status, Json(body)).into_response()
162    }
163}