use sea_orm::DbErr; pub fn is_unique_violation(err: &DbErr) -> bool { let msg = err.to_string().to_lowercase(); msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505") } /// Generates the `From` conversions shared by all domain error enums: /// `DbErr`, `TransactionError` and `TransactionError`. /// The target enum must have `Conflict(String)` and `Internal(String)` variants. #[macro_export] macro_rules! db_error_conversions { ($name:ident) => { impl From<::sea_orm::DbErr> for $name { fn from(error: ::sea_orm::DbErr) -> Self { if $crate::is_unique_violation(&error) { return Self::Conflict("resource already exists".to_string()); } Self::Internal(format!("database error: {error}")) } } impl From<::sea_orm::TransactionError<::sea_orm::DbErr>> for $name { fn from(error: ::sea_orm::TransactionError<::sea_orm::DbErr>) -> Self { match &error { ::sea_orm::TransactionError::Transaction(db_err) if $crate::is_unique_violation(db_err) => { Self::Conflict("resource already exists".to_string()) } _ => Self::Internal(format!("database transaction error: {error}")), } } } impl From<::sea_orm::TransactionError<$name>> for $name { fn from(error: ::sea_orm::TransactionError<$name>) -> Self { match error { ::sea_orm::TransactionError::Connection(db_err) => Self::from(db_err), ::sea_orm::TransactionError::Transaction(err) => err, } } } }; } #[macro_export] macro_rules! json_payload_conversion { ($name:ident) => { impl From<::serde_json::Error> for $name { fn from(error: ::serde_json::Error) -> Self { Self::BadRequest(format!("invalid payload: {error}")) } } }; } #[macro_export] macro_rules! page_error_conversion { ($name:ident, $variant:ident) => { impl From<$crate::pagination::PageError> for $name { fn from(error: $crate::pagination::PageError) -> Self { Self::$variant(error.to_string()) } } }; } /// Standard variant-to-variant mapping between two domain error enums sharing /// the `BadRequest/Unauthorized/Forbidden/NotFound/Conflict/RateLimited/Internal` /// shape; non-matching source variants fall through to `Internal`. #[macro_export] macro_rules! domain_error_passthrough { ($to:ident from $($src:tt)*) => { impl From<$($src)*> for $to { fn from(error: $($src)*) -> Self { #[allow(unreachable_patterns)] match error { $($src)*::BadRequest(msg) => Self::BadRequest(msg), $($src)*::Unauthorized => Self::Unauthorized, $($src)*::Forbidden => Self::Forbidden, $($src)*::NotFound => Self::NotFound, $($src)*::Conflict(msg) => Self::Conflict(msg), $($src)*::RateLimited(msg) => Self::RateLimited(msg), $($src)*::Internal(msg) => Self::Internal(msg), other => Self::Internal(other.to_string()), } } } }; } /// Generates a crate's `ApiError` response enum plus its boilerplate: /// `IntoResponses` derive, envelope constructors, `IntoResponse`, and the /// `From` impls shared by every crate (boxed self, `AppResponseData<()>`, /// request rejections, `serde_json::Error`, `(StatusCode, &str)` and /// `session::SessionError`). /// /// The enum must contain an `Internal` variant; `log` labels server-side /// warnings as "` handler failed`". #[macro_export] macro_rules! api_error { ( log: $log:literal, $(#[$meta:meta])* $vis:vis enum $name:ident { $( $variant:ident ( $status:literal, $desc:literal ) ),* $(,)? } ) => { $(#[$meta])* #[derive(Debug, ::utoipa::IntoResponses)] $vis enum $name { $( #[response(status = $status, description = $desc)] $variant(#[to_schema] ::std::boxed::Box<$crate::rest::AppResponseData<$crate::rest::NoData>>), )* } impl $name { fn payload( code: i32, message: &'static str, ) -> $crate::rest::AppResponseData<$crate::rest::NoData> { $crate::rest::AppResponseData::err(code, message) } /// Wraps a plain envelope into the enum's boxed payload. The box is /// what keeps `Result` small: an unboxed envelope is /// 128 bytes, which every handler return type would otherwise carry. pub fn build( status: u16, envelope: $crate::rest::AppResponseData<$crate::rest::NoData>, ) -> Self { let envelope = ::std::boxed::Box::new(envelope); #[allow(unreachable_patterns)] match status { $( $status => Self::$variant(envelope), )* _ => Self::Internal(envelope), } } pub fn bad_request(message: impl ::std::convert::AsRef) -> Self { let message = message.as_ref(); Self::build( 400, $crate::rest::AppResponseData::err(400, message) .with_error_code("bad_request"), ) } pub fn unauthorized() -> Self { Self::build( 401, Self::payload(401, "unauthorized").with_error_code("unauthorized"), ) } pub fn forbidden() -> Self { Self::build( 403, Self::payload(403, "forbidden").with_error_code("forbidden"), ) } pub fn not_found(message: &'static str) -> Self { Self::build( 404, Self::payload(404, message).with_error_code("not_found"), ) } pub fn conflict(message: impl ::std::convert::AsRef) -> Self { let message = message.as_ref(); Self::build( 409, $crate::rest::AppResponseData::err(409, message).with_error_code("conflict"), ) } pub fn forbidden_with_code( error_code: impl ::std::convert::Into, message: impl ::std::convert::Into, ) -> Self { Self::build( 403, $crate::rest::AppResponseData::err(403, message) .with_error_code(error_code), ) } pub fn conflict_with_code( error_code: impl ::std::convert::Into, message: impl ::std::convert::Into, ) -> Self { Self::build( 409, $crate::rest::AppResponseData::err(409, message) .with_error_code(error_code), ) } pub fn rate_limited(message: impl ::std::convert::Into) -> Self { Self::build( 429, $crate::rest::AppResponseData::err(429, message) .with_error_code("rate_limited"), ) } pub fn rate_limited_exceeded() -> Self { Self::build( 429, Self::payload(429, "rate limit exceeded").with_error_code("rate_limited"), ) } pub fn internal(message: impl ::std::fmt::Display) -> Self { ::tracing::warn!(error = %message, concat!($log, " handler failed")); Self::build(500, Self::payload(500, "internal server error")) } } impl ::std::fmt::Display for $name { fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { match self { $( Self::$variant(envelope) => formatter.write_str(&envelope.message), )* } } } impl ::actix_web::ResponseError for $name { fn status_code(&self) -> ::actix_web::http::StatusCode { let status = match self { $( Self::$variant(_) => $status, )* }; ::actix_web::http::StatusCode::from_u16(status) .unwrap_or(::actix_web::http::StatusCode::INTERNAL_SERVER_ERROR) } fn error_response(&self) -> ::actix_web::HttpResponse { let (status, mut envelope) = match self { $( Self::$variant(envelope) => ($status, (**envelope).clone()), )* }; envelope.request_id = $crate::middleware::request_id().unwrap_or_default(); envelope.code = status; envelope.into_http_response() } } impl From> for $name { fn from(error: Box<$name>) -> Self { *error } } impl From<$crate::rest::AppResponseData<()>> for $name { fn from(_error: $crate::rest::AppResponseData<()>) -> Self { Self::build(400, Self::payload(400, "bad request")) } } impl From<::serde_json::Error> for $name { fn from(_error: ::serde_json::Error) -> Self { Self::build(400, Self::payload(400, "bad request")) } } impl From<$crate::pagination::PageError> for $name { fn from(error: $crate::pagination::PageError) -> Self { Self::bad_request(error.to_string()) } } impl From<(::actix_web::http::StatusCode, &'static str)> for $name { fn from((status, message): (::actix_web::http::StatusCode, &'static str)) -> Self { ::tracing::error!(%status, %message, "unhandled (status, message) converted to 500"); Self::build(500, Self::payload(500, "internal server error")) } } impl From<::session::SessionError> for $name { fn from(error: ::session::SessionError) -> Self { match error { ::session::SessionError::Unauthorized => { Self::build(401, Self::payload(401, "unauthenticated")) } ::session::SessionError::Purged => { Self::build(401, Self::payload(401, "session_revoked")) } other => { ::tracing::error!(error = %other, "session backend error"); Self::build(500, Self::payload(500, "internal server error")) } } } } }; }