use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use crate::middleware::locale::current_locale;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AppError {
#[error("bad request: {0}")]
BadRequest(String),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("not found: {0}")]
NotFound(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("method not allowed: {0}")]
MethodNotAllowed(String),
#[error("payload too large: {0}")]
PayloadTooLarge(String),
#[error("too many requests: {0}")]
TooManyRequests(String),
#[error("service unavailable: {0}")]
ServiceUnavailable(String),
#[error("{0}")]
Internal(#[from] anyhow::Error),
}
impl AppError {
#[must_use]
pub fn not_found(resource: &str) -> Self {
AppError::NotFound(resource.into())
}
pub fn expect_affected(result: &crate::db::DbQueryResult, resource: &str) -> AppResult<()> {
if result.rows_affected() == 0 {
Err(AppError::NotFound(resource.into()))
} else {
Ok(())
}
}
fn i18n_message(&self, locale: &str) -> String {
match self {
AppError::BadRequest(msg) => {
rust_i18n::t!("errors.bad_request", locale = locale, message = msg).to_string()
}
AppError::Unauthorized => {
rust_i18n::t!("errors.unauthorized", locale = locale).to_string()
}
AppError::Forbidden => rust_i18n::t!("errors.forbidden", locale = locale).to_string(),
AppError::NotFound(resource_key) => {
let res_key = format!("resources.{resource_key}");
let resource = rust_i18n::t!(&res_key, locale = locale);
rust_i18n::t!("errors.not_found", locale = locale, resource = resource).to_string()
}
AppError::Conflict(msg_key) => {
let msg_key_full = format!("messages.{msg_key}");
let message = rust_i18n::t!(&msg_key_full, locale = locale);
rust_i18n::t!("errors.conflict", locale = locale, message = message).to_string()
}
AppError::PayloadTooLarge(detail) => {
let base = rust_i18n::t!("errors.payload_too_large", locale = locale).to_string();
format!("{base}: {detail}")
}
AppError::TooManyRequests(detail) => {
let base = rust_i18n::t!("errors.too_many_requests", locale = locale).to_string();
format!("{base}: {detail}")
}
AppError::MethodNotAllowed(detail) => {
let base = rust_i18n::t!("errors.method_not_allowed", locale = locale).to_string();
format!("{base}: {detail}")
}
AppError::ServiceUnavailable(detail) => {
let base = rust_i18n::t!("errors.service_unavailable", locale = locale).to_string();
format!("{base}: {detail}")
}
AppError::Internal(err) => {
let base: String = rust_i18n::t!("errors.internal", locale = locale).into();
if std::env::var("APP_ENV").unwrap_or_default() == "production" {
base
} else {
format!("{base}: {err}")
}
}
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, 40000),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, 40100),
AppError::Forbidden => (StatusCode::FORBIDDEN, 40300),
AppError::NotFound(_) => (StatusCode::NOT_FOUND, 40400),
AppError::MethodNotAllowed(_) => (StatusCode::METHOD_NOT_ALLOWED, 40500),
AppError::Conflict(_) => (StatusCode::CONFLICT, 40900),
AppError::PayloadTooLarge(_) => (StatusCode::PAYLOAD_TOO_LARGE, 41300),
AppError::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, 42900),
AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, 50000),
AppError::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, 50300),
};
let locale = current_locale();
let message = self.i18n_message(&locale);
match status {
StatusCode::BAD_REQUEST
| StatusCode::UNAUTHORIZED
| StatusCode::FORBIDDEN
| StatusCode::NOT_FOUND
| StatusCode::CONFLICT => {
tracing::warn!(%code, %message, error = %self, "client error");
}
_ => {
tracing::error!(%code, %message, error = %self, "server error");
}
}
let body = ErrorBody {
code,
message,
data: (),
};
(status, Json(body)).into_response()
}
}
#[derive(Serialize)]
struct ErrorBody {
code: i32,
message: String,
data: (),
}
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => AppError::NotFound("resource".into()),
sqlx::Error::Database(ref e) => {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") || msg.contains("duplicate key value") {
tracing::warn!(constraint = ?e.constraint(), "unique constraint violation");
AppError::Conflict("duplicate_entry".into())
} else {
AppError::Internal(err.into())
}
}
other => AppError::Internal(other.into()),
}
}
}
pub type AppResult<T> = Result<T, AppError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_found_convenience() {
let err = AppError::not_found("post");
match err {
AppError::NotFound(r) => assert_eq!(r, "post"),
_ => panic!("expected NotFound"),
}
}
#[test]
fn expect_affected_zero_rows() {
let result = crate::db::DbQueryResult::default();
let outcome = AppError::expect_affected(&result, "user");
assert!(outcome.is_err());
match outcome.unwrap_err() {
AppError::NotFound(r) => assert_eq!(r, "user"),
_ => panic!("expected NotFound"),
}
}
#[test]
fn from_sqlx_row_not_found() {
let err: AppError = sqlx::Error::RowNotFound.into();
match err {
AppError::NotFound(_) => {}
_ => panic!("expected NotFound"),
}
}
#[test]
fn from_anyhow() {
let err = AppError::Internal(anyhow::anyhow!("boom"));
match err {
AppError::Internal(e) => assert_eq!(e.to_string(), "boom"),
_ => panic!("expected Internal"),
}
}
}