use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use serde::Serialize;
use serde_json::{Value, json};
use utoipa::ToSchema;
use crate::validation::ValidationError;
pub const CODE_INTERNAL: &str = "internal";
pub const CODE_VALIDATION: &str = "validation.failed";
pub const CODE_DATABASE: &str = "infrastructure.database";
pub const CODE_UNAVAILABLE: &str = "infrastructure.unavailable";
pub const CODE_UNAUTHORIZED: &str = "unauthorized";
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ErrorBody {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Object>)]
pub details: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct MappedError {
status: StatusCode,
body: ErrorBody,
}
impl MappedError {
pub fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status,
body: ErrorBody {
code: code.into(),
message: message.into(),
details: None,
},
}
}
pub fn with_details(mut self, details: Value) -> Self {
self.body.details = Some(details);
self
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, "not_found", message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, "conflict", message)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, CODE_UNAUTHORIZED, message)
}
pub fn status(&self) -> StatusCode {
self.status
}
pub fn body(&self) -> &ErrorBody {
&self.body
}
pub fn into_response(self) -> HttpResponse {
HttpResponse::build(self.status).json(self.body)
}
}
impl std::fmt::Display for MappedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.body.code, self.body.message)
}
}
impl std::error::Error for MappedError {}
#[derive(Debug, Clone)]
pub struct HttpError(MappedError);
pub type HttpResult<T = HttpResponse> = Result<T, HttpError>;
impl HttpError {
pub fn inner(&self) -> &MappedError {
&self.0
}
pub fn into_mapped(self) -> MappedError {
self.0
}
}
impl From<MappedError> for HttpError {
fn from(value: MappedError) -> Self {
Self(value)
}
}
impl From<Box<dyn std::error::Error + Send + Sync>> for HttpError {
fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
Self(map_error(&*err))
}
}
impl From<ValidationError> for HttpError {
fn from(err: ValidationError) -> Self {
Self(map_error(&err))
}
}
impl From<sqlx::Error> for HttpError {
fn from(err: sqlx::Error) -> Self {
Self(map_sqlx(&err))
}
}
impl std::fmt::Display for HttpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for HttpError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
impl ResponseError for HttpError {
fn status_code(&self) -> StatusCode {
self.0.status()
}
fn error_response(&self) -> HttpResponse {
self.0.clone().into_response()
}
}
pub fn map_error(err: &(dyn std::error::Error + Send + Sync + 'static)) -> MappedError {
if let Some(http) = err.downcast_ref::<HttpError>() {
return http.0.clone();
}
if let Some(mapped) = err.downcast_ref::<MappedError>() {
return mapped.clone();
}
if let Some(validation) = err.downcast_ref::<ValidationError>() {
return MappedError::new(
StatusCode::BAD_REQUEST,
CODE_VALIDATION,
"Validation failed",
)
.with_details(json!({ "fields": validation.fields() }));
}
if let Some(sqlx_err) = err.downcast_ref::<sqlx::Error>() {
return map_sqlx(sqlx_err);
}
MappedError::new(
StatusCode::INTERNAL_SERVER_ERROR,
CODE_INTERNAL,
"Internal server error",
)
}
pub fn error_response(err: &(dyn std::error::Error + Send + Sync + 'static)) -> HttpResponse {
map_error(err).into_response()
}
pub fn error_ws_envelope(err: &(dyn std::error::Error + Send + Sync + 'static)) -> String {
#[derive(Serialize)]
struct Envelope<'a> {
name: &'a str,
data: &'a ErrorBody,
}
let mapped = map_error(err);
serde_json::to_string(&Envelope {
name: "error",
data: mapped.body(),
})
.expect("error envelope")
}
fn map_sqlx(err: &sqlx::Error) -> MappedError {
match err {
sqlx::Error::RowNotFound => MappedError::not_found("Not found"),
sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::WorkerCrashed => {
MappedError::new(
StatusCode::SERVICE_UNAVAILABLE,
CODE_UNAVAILABLE,
"Database unavailable",
)
}
_ => {
let mapped = MappedError::new(StatusCode::BAD_GATEWAY, CODE_DATABASE, "Database error");
if crate::config::Environment::current().exposes_infra_errors() {
mapped.with_details(json!({ "reason": err.to_string() }))
} else {
mapped
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::validation::ValidationError;
#[test]
fn mapped_error_is_preserved() {
let src = MappedError::not_found("user missing");
let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(src.clone());
let got = map_error(&*boxed);
assert_eq!(got.status(), StatusCode::NOT_FOUND);
assert_eq!(got.body().code, "not_found");
assert_eq!(got.body().message, "user missing");
}
#[test]
fn sqlx_protocol_is_bad_gateway() {
let err = sqlx::Error::Protocol("boom".into());
let got = map_error(&err);
assert_eq!(got.status(), StatusCode::BAD_GATEWAY);
assert_eq!(got.body().code, CODE_DATABASE);
assert!(got.body().details.is_none());
}
#[test]
fn sqlx_reason_only_in_development() {
crate::config::with_environment(crate::config::Environment::Development, || {
let got = map_error(&sqlx::Error::Protocol("boom".into()));
let reason = got.body().details.as_ref().unwrap()["reason"]
.as_str()
.unwrap();
assert!(reason.contains("boom"), "{reason}");
});
let prod = map_error(&sqlx::Error::Protocol("boom".into()));
assert!(prod.body().details.is_none());
}
#[test]
fn sqlx_row_not_found_is_not_found() {
let got = map_error(&sqlx::Error::RowNotFound);
assert_eq!(got.status(), StatusCode::NOT_FOUND);
assert_eq!(got.body().code, "not_found");
assert!(got.body().details.is_none());
}
#[test]
fn sqlx_pool_timeout_is_unavailable() {
let got = map_error(&sqlx::Error::PoolTimedOut);
assert_eq!(got.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(got.body().code, CODE_UNAVAILABLE);
}
#[test]
fn validation_is_bad_request() {
let mut err = ValidationError::new();
err.add("email", "must not be empty");
let got = map_error(&err);
assert_eq!(got.status(), StatusCode::BAD_REQUEST);
assert_eq!(got.body().code, CODE_VALIDATION);
assert!(got.body().details.is_some());
}
#[test]
fn unknown_is_internal() {
let err = std::io::Error::other("nope");
let got = map_error(&err);
assert_eq!(got.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(got.body().code, CODE_INTERNAL);
}
#[test]
fn question_mark_maps_send_error() {
fn send_like() -> Result<u8, Box<dyn std::error::Error + Send + Sync>> {
Err(Box::new(sqlx::Error::PoolTimedOut))
}
fn route() -> HttpResult<u8> {
Ok(send_like()?)
}
let err = route().unwrap_err();
assert_eq!(err.inner().status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(err.inner().body().code, CODE_UNAVAILABLE);
}
#[test]
fn validation_question_mark() {
fn check() -> HttpResult<()> {
let req = crate::page::PageRequest {
page: 0,
page_size: 20,
};
req.validate()?;
Ok(())
}
let err = check().unwrap_err();
assert_eq!(err.inner().status(), StatusCode::BAD_REQUEST);
}
#[actix_web::test]
async fn handler_can_return_http_error() {
use actix_web::{App, test, web};
async fn boom() -> HttpResult {
Err(MappedError::not_found("missing").into())
}
let srv = test::init_service(App::new().route("/", web::get().to(boom))).await;
let resp = test::call_service(&srv, test::TestRequest::get().uri("/").to_request()).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}