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("forbidden: {0}")]
51    Forbidden(String),
52    #[error("bulk validation failed")]
53    BulkValidation(Vec<BulkFieldError>),
54}
55
56#[derive(Serialize)]
57pub struct ErrorBody {
58    pub error: ErrorDetail,
59}
60
61#[derive(Serialize)]
62pub struct ErrorDetail {
63    pub code: String,
64    pub message: String,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub details: Option<serde_json::Value>,
67}
68
69/// Extract the column name from a PostgreSQL error.
70/// Extract a field name from a database constraint violation error, where available.
71/// On Postgres, this parses the `detail` field of `PgDatabaseError` (e.g. "Key (email)=... already exists").
72/// On other dialects the function returns `None` — callers fall back to generic messages.
73pub fn db_error_field(e: &AppError) -> Option<String> {
74    #[cfg(feature = "postgres")]
75    if let AppError::Db(sqlx::Error::Database(ref db_err)) = e {
76        if let Some(pg_err) = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>() {
77            if let Some(detail) = pg_err.detail() {
78                if let Some(start) = detail.find('(') {
79                    if let Some(end) = detail[start + 1..].find(')') {
80                        let field = &detail[start + 1..start + 1 + end];
81                        if !field.is_empty() && !field.contains(',') {
82                            return Some(field.trim().to_string());
83                        }
84                    }
85                }
86            }
87        }
88    }
89    #[cfg(not(feature = "postgres"))]
90    let _ = e;
91    None
92}
93
94/// Build a human-readable message for a DB error, using the extracted field name when available.
95pub fn db_error_message(e: &AppError, field: Option<&str>) -> String {
96    if let AppError::Db(sqlx::Error::Database(ref db_err)) = e {
97        match db_err.kind() {
98            sqlx::error::ErrorKind::UniqueViolation => {
99                return match field {
100                    Some(f) => format!("{} already exists", f),
101                    None => "duplicate value violates unique constraint".to_string(),
102                }
103            }
104            sqlx::error::ErrorKind::ForeignKeyViolation => {
105                return match field {
106                    Some(f) => format!("{} references a non-existent record", f),
107                    None => "foreign key constraint violation".to_string(),
108                }
109            }
110            sqlx::error::ErrorKind::NotNullViolation => {
111                return match field {
112                    Some(f) => format!("{} cannot be null", f),
113                    None => "not null constraint violation".to_string(),
114                }
115            }
116            sqlx::error::ErrorKind::CheckViolation => {
117                return "check constraint violation".to_string();
118            }
119            _ => {}
120        }
121    }
122    e.to_string()
123}
124
125impl IntoResponse for AppError {
126    fn into_response(self) -> Response {
127        if let AppError::BulkValidation(ref errors) = self {
128            let affected: std::collections::HashSet<usize> =
129                errors.iter().map(|e| e.index).collect();
130            let body = ErrorBody {
131                error: ErrorDetail {
132                    code: "bulk_validation_error".to_string(),
133                    message: format!("Validation failed for {} item(s)", affected.len()),
134                    details: Some(serde_json::to_value(errors).unwrap_or(serde_json::Value::Null)),
135                },
136            };
137            return (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response();
138        }
139        let (status, code) = match &self {
140            AppError::Config(_) => (StatusCode::INTERNAL_SERVER_ERROR, "config_error"),
141            AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
142            AppError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation_error"),
143            AppError::Db(e) => {
144                if let sqlx::Error::RowNotFound = e {
145                    (StatusCode::NOT_FOUND, "not_found")
146                } else {
147                    (StatusCode::INTERNAL_SERVER_ERROR, "database_error")
148                }
149            }
150            AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
151            AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
152            AppError::Storage(_) => (StatusCode::INTERNAL_SERVER_ERROR, "storage_error"),
153            AppError::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
154            AppError::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
155            AppError::BulkValidation(_) => unreachable!(),
156        };
157        let body = ErrorBody {
158            error: ErrorDetail {
159                code: code.to_string(),
160                message: self.to_string(),
161                details: None,
162            },
163        };
164        (status, Json(body)).into_response()
165    }
166}