use actix_web::http::StatusCode;
use actix_web::HttpResponse;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldError {
pub field: String,
pub message: String,
pub code: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ShaperailError {
#[error("Resource not found")]
NotFound,
#[error("Unauthorized")]
Unauthorized,
#[error("Forbidden")]
Forbidden,
#[error("Validation failed")]
Validation(Vec<FieldError>),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Rate limit exceeded")]
RateLimited,
#[error("Internal server error: {0}")]
Internal(String),
}
impl ShaperailError {
pub fn code(&self) -> &'static str {
match self {
Self::NotFound => "NOT_FOUND",
Self::Unauthorized => "UNAUTHORIZED",
Self::Forbidden => "FORBIDDEN",
Self::Validation(_) => "VALIDATION_ERROR",
Self::Conflict(_) => "CONFLICT",
Self::RateLimited => "RATE_LIMITED",
Self::Internal(_) => "INTERNAL_ERROR",
}
}
pub fn status(&self) -> StatusCode {
match self {
Self::NotFound => StatusCode::NOT_FOUND,
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden => StatusCode::FORBIDDEN,
Self::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn to_error_body(&self, request_id: &str) -> serde_json::Value {
let details = match self {
Self::Validation(errors) => Some(serde_json::to_value(errors).unwrap_or_default()),
_ => None,
};
serde_json::json!({
"error": {
"code": self.code(),
"status": self.status().as_u16(),
"message": self.to_string(),
"request_id": request_id,
"details": details,
}
})
}
}
impl actix_web::ResponseError for ShaperailError {
fn status_code(&self) -> StatusCode {
self.status()
}
fn error_response(&self) -> HttpResponse {
let body = self.to_error_body("unknown");
HttpResponse::build(self.status()).json(body)
}
}
impl From<sqlx::Error> for ShaperailError {
fn from(err: sqlx::Error) -> Self {
match &err {
sqlx::Error::RowNotFound => Self::NotFound,
sqlx::Error::Database(db_err) => {
if db_err.code().as_deref() == Some("23505") {
Self::Conflict(db_err.message().to_string())
} else {
Self::Internal(err.to_string())
}
}
_ => Self::Internal(err.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_codes() {
assert_eq!(ShaperailError::NotFound.code(), "NOT_FOUND");
assert_eq!(ShaperailError::Unauthorized.code(), "UNAUTHORIZED");
assert_eq!(ShaperailError::Forbidden.code(), "FORBIDDEN");
assert_eq!(
ShaperailError::Validation(vec![]).code(),
"VALIDATION_ERROR"
);
assert_eq!(
ShaperailError::Conflict("dup".to_string()).code(),
"CONFLICT"
);
assert_eq!(ShaperailError::RateLimited.code(), "RATE_LIMITED");
assert_eq!(
ShaperailError::Internal("oops".to_string()).code(),
"INTERNAL_ERROR"
);
}
#[test]
fn error_status_codes() {
assert_eq!(ShaperailError::NotFound.status(), StatusCode::NOT_FOUND);
assert_eq!(
ShaperailError::Unauthorized.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(ShaperailError::Forbidden.status(), StatusCode::FORBIDDEN);
assert_eq!(
ShaperailError::Validation(vec![]).status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
ShaperailError::Conflict("x".to_string()).status(),
StatusCode::CONFLICT
);
assert_eq!(
ShaperailError::RateLimited.status(),
StatusCode::TOO_MANY_REQUESTS
);
assert_eq!(
ShaperailError::Internal("x".to_string()).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn error_display() {
assert_eq!(ShaperailError::NotFound.to_string(), "Resource not found");
assert_eq!(ShaperailError::Unauthorized.to_string(), "Unauthorized");
assert_eq!(ShaperailError::Forbidden.to_string(), "Forbidden");
assert_eq!(
ShaperailError::Validation(vec![]).to_string(),
"Validation failed"
);
assert_eq!(
ShaperailError::Conflict("duplicate email".to_string()).to_string(),
"Conflict: duplicate email"
);
assert_eq!(
ShaperailError::RateLimited.to_string(),
"Rate limit exceeded"
);
assert_eq!(
ShaperailError::Internal("db down".to_string()).to_string(),
"Internal server error: db down"
);
}
#[test]
fn error_body_matches_prd_shape() {
let body = ShaperailError::NotFound.to_error_body("req-123");
let error = &body["error"];
assert_eq!(error["code"], "NOT_FOUND");
assert_eq!(error["status"], 404);
assert_eq!(error["message"], "Resource not found");
assert_eq!(error["request_id"], "req-123");
assert!(error["details"].is_null());
}
#[test]
fn error_body_validation_includes_details() {
let errors = vec![
FieldError {
field: "email".to_string(),
message: "is required".to_string(),
code: "required".to_string(),
},
FieldError {
field: "name".to_string(),
message: "too short".to_string(),
code: "too_short".to_string(),
},
];
let body = ShaperailError::Validation(errors).to_error_body("req-456");
let details = &body["error"]["details"];
assert!(details.is_array());
assert_eq!(details.as_array().unwrap().len(), 2);
assert_eq!(details[0]["field"], "email");
}
#[test]
fn field_error_serde() {
let fe = FieldError {
field: "email".to_string(),
message: "is required".to_string(),
code: "required".to_string(),
};
let json = serde_json::to_string(&fe).unwrap();
let back: FieldError = serde_json::from_str(&json).unwrap();
assert_eq!(fe, back);
}
#[test]
fn from_sqlx_row_not_found() {
let err: ShaperailError = sqlx::Error::RowNotFound.into();
assert!(matches!(err, ShaperailError::NotFound));
}
}