use axum::{
Json,
extract::rejection::JsonRejection,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Clone, Serialize)]
pub struct ErrorDetail {
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errors: Option<serde_json::Value>,
}
impl ErrorDetail {
#[must_use]
pub fn new<A: Into<String>, B: Into<String> + AsRef<str>>(error: A, description: B) -> Self {
let description = (!description.as_ref().is_empty()).then(|| description.into());
Self {
error: Some(error.into()),
description,
errors: None,
}
}
#[must_use]
pub fn with_reason<A: Into<String>>(error: A) -> Self {
Self {
error: Some(error.into()),
description: None,
errors: None,
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("{0}")]
Message(String),
#[error("not found")]
NotFound,
#[error("{0}")]
Unauthorized(String),
#[error("{0}")]
BadRequest(String),
#[error("internal server error")]
InternalServerError,
#[error("{0} {1:?}")]
CustomError(StatusCode, ErrorDetail),
#[error(transparent)]
JsonRejection(#[from] JsonRejection),
#[error(transparent)]
Axum(#[from] axum::Error),
#[error(transparent)]
Http(#[from] axum::http::Error),
#[error(transparent)]
IO(#[from] std::io::Error),
#[error(transparent)]
JSON(#[from] serde_json::Error),
#[error("worker error: {0}")]
Worker(String),
#[cfg(feature = "sea-orm")]
#[error(transparent)]
Model(#[from] crate::model::ModelError),
#[cfg(feature = "sea-orm")]
#[error(transparent)]
DB(#[from] sea_orm::DbErr),
#[cfg(feature = "queue")]
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error(transparent)]
Any(#[from] Box<dyn std::error::Error + Send + Sync>),
}
impl Error {
#[must_use]
pub fn string(message: &str) -> Self {
Self::Message(message.to_string())
}
#[must_use]
pub fn wrap(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::Any(Box::new(err))
}
}
impl From<String> for Error {
fn from(message: String) -> Self {
Self::Message(message)
}
}
impl From<&str> for Error {
fn from(message: &str) -> Self {
Self::Message(message.to_string())
}
}
fn internal() -> (StatusCode, ErrorDetail) {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorDetail::new("internal_server_error", "Internal Server Error"),
)
}
fn resource_not_found() -> (StatusCode, ErrorDetail) {
(
StatusCode::NOT_FOUND,
ErrorDetail::new("not_found", "Resource was not found"),
)
}
impl Error {
fn log(&self) {
let client_mistake = match self {
Self::NotFound | Self::BadRequest(_) | Self::JsonRejection(_) => true,
Self::CustomError(status, _) => status.is_client_error(),
#[cfg(feature = "sea-orm")]
Self::Model(
crate::model::ModelError::EntityNotFound
| crate::model::ModelError::EntityAlreadyExists
| crate::model::ModelError::Validation(_),
) => true,
_ => false,
};
if client_mistake {
tracing::debug!("controller_client_error");
} else if let Self::Unauthorized(reason) = self {
tracing::warn!(error.msg = %reason, "controller_unauthorized");
} else {
tracing::error!(error.msg = %self, error.details = ?self, "controller_error");
}
}
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
self.log();
let (status, detail) = match self {
Self::NotFound => resource_not_found(),
Self::Unauthorized(_) => (
StatusCode::UNAUTHORIZED,
ErrorDetail::new(
"unauthorized",
"You do not have permission to access this resource",
),
),
Self::BadRequest(message) => (
StatusCode::BAD_REQUEST,
ErrorDetail::new("Bad Request", message),
),
Self::CustomError(status, detail) => (status, detail),
Self::JsonRejection(rejection) => {
(rejection.status(), ErrorDetail::with_reason("Bad Request"))
}
#[cfg(feature = "sea-orm")]
Self::Model(crate::model::ModelError::EntityNotFound) => resource_not_found(),
#[cfg(feature = "sea-orm")]
Self::Model(crate::model::ModelError::EntityAlreadyExists) => (
StatusCode::CONFLICT,
ErrorDetail::new("conflict", "Resource already exists"),
),
#[cfg(feature = "sea-orm")]
Self::Model(crate::model::ModelError::Validation(errors)) => (
StatusCode::BAD_REQUEST,
ErrorDetail {
error: None,
description: None,
errors: Some(serde_json::to_value(&errors.errors).unwrap_or_default()),
},
),
Self::Message(_)
| Self::InternalServerError
| Self::Axum(_)
| Self::Http(_)
| Self::IO(_)
| Self::JSON(_)
| Self::Worker(_)
| Self::Any(_) => internal(),
#[cfg(feature = "sea-orm")]
Self::Model(_) | Self::DB(_) => internal(),
#[cfg(feature = "queue")]
Self::Sqlx(_) => internal(),
};
(status, Json(detail)).into_response()
}
}
pub fn not_found<T>() -> Result<T> {
Err(Error::NotFound)
}
pub fn unauthorized<T>(message: &str) -> Result<T> {
Err(Error::Unauthorized(message.to_string()))
}
pub fn bad_request<T>(message: &str) -> Result<T> {
Err(Error::BadRequest(message.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
async fn body_text(response: Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn internal_errors_do_not_leak_their_text() {
let response = Error::Message("connection to 10.0.0.1 refused".into()).into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = body_text(response).await;
assert!(!body.contains("10.0.0.1"), "{body}");
assert!(body.contains("internal_server_error"));
}
#[tokio::test]
async fn unauthorized_text_is_logged_but_not_returned() {
let response = Error::Unauthorized("token signature invalid".into()).into_response();
assert!(!body_text(response).await.contains("signature"));
}
#[tokio::test]
async fn bad_request_text_is_returned() {
let body = body_text(Error::BadRequest("name is required".into()).into_response()).await;
assert!(body.contains("name is required"));
}
#[tokio::test]
async fn custom_errors_keep_their_status_and_body() {
let error = Error::CustomError(StatusCode::CONFLICT, ErrorDetail::with_reason("duplicate"));
let response = error.into_response();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(body_text(response).await.contains("duplicate"));
}
#[test]
fn helpers_build_the_matching_variants() {
assert!(matches!(not_found::<()>(), Err(Error::NotFound)));
assert!(matches!(
unauthorized::<()>("x"),
Err(Error::Unauthorized(_))
));
assert!(matches!(bad_request::<()>("x"), Err(Error::BadRequest(_))));
}
#[test]
fn text_converts_to_a_message() {
assert!(matches!(Error::from("x"), Error::Message(_)));
assert!(matches!(Error::from("x".to_string()), Error::Message(_)));
assert!(matches!(Error::string("x"), Error::Message(_)));
}
#[test]
fn wrap_keeps_the_source_text() {
let error = Error::wrap(std::io::Error::other("disk full"));
assert_eq!(error.to_string(), "disk full");
}
#[test]
fn statuses_match_their_variants() {
assert_eq!(Error::NotFound.into_response().status(), 404);
assert_eq!(Error::BadRequest("x".into()).into_response().status(), 400);
assert_eq!(
Error::Unauthorized("x".into()).into_response().status(),
401
);
}
#[test]
fn empty_description_is_omitted() {
assert_eq!(ErrorDetail::new("x", "").description, None);
}
}