pub use api_bones::error::{ApiError, ErrorCode, ProblemJson, ValidationError};
use axum::response::{IntoResponse, Response};
#[derive(Debug)]
pub struct HandlerError(pub ApiError);
impl HandlerError {
pub fn new(code: ErrorCode, detail: impl Into<String>) -> Self {
Self(ApiError::new(code, detail))
}
pub fn with_request_id(mut self, id: uuid::Uuid) -> Self {
self.0 = self.0.with_request_id(id);
self
}
pub fn with_errors(mut self, errors: Vec<ValidationError>) -> Self {
self.0 = self.0.with_errors(errors);
self
}
#[cfg(feature = "database")]
pub fn from_sqlx(err: &sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => {
Self::new(ErrorCode::ResourceNotFound, "resource not found")
}
sqlx::Error::Database(db_err) => {
if db_err.code().as_deref() == Some("23505") {
Self::new(ErrorCode::ResourceAlreadyExists, "resource already exists")
} else {
tracing::error!(error = %err, "database error");
Self::new(ErrorCode::InternalServerError, "internal server error")
}
}
_ => {
tracing::error!(error = %err, "database error");
Self::new(ErrorCode::InternalServerError, "internal server error")
}
}
}
}
impl From<ApiError> for HandlerError {
fn from(e: ApiError) -> Self {
Self(e)
}
}
impl IntoResponse for HandlerError {
fn into_response(self) -> Response {
ProblemJson::from(self.0).into_response()
}
}
pub type HandlerResponse<T> = Result<
(
axum::http::StatusCode,
axum::Json<api_bones::ApiResponse<T>>,
),
HandlerError,
>;
pub type HandlerListResponse<T> =
Result<axum::Json<api_bones::ApiResponse<api_bones::PaginatedResponse<T>>>, HandlerError>;
pub type CreatedResponse<T> = Result<
(
axum::http::StatusCode,
axum::Json<api_bones::ApiResponse<T>>,
),
HandlerError,
>;
pub type EtaggedHandlerResponse<T> = Result<
(
api_bones::etag::ETag,
axum::http::StatusCode,
axum::Json<api_bones::ApiResponse<T>>,
),
HandlerError,
>;
pub fn created<T>(value: T) -> CreatedResponse<T> {
Ok((
axum::http::StatusCode::CREATED,
axum::Json(api_bones::ApiResponse::builder(value).build()),
))
}
pub fn ok<T>(value: T) -> HandlerResponse<T> {
Ok((
axum::http::StatusCode::OK,
axum::Json(api_bones::ApiResponse::builder(value).build()),
))
}
pub fn listed<T>(page: api_bones::PaginatedResponse<T>) -> HandlerListResponse<T> {
Ok(axum::Json(api_bones::ApiResponse::builder(page).build()))
}
pub(crate) fn panic_handler(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
let detail = if let Some(s) = err.downcast_ref::<String>() {
s.as_str()
} else if let Some(s) = err.downcast_ref::<&str>() {
s
} else {
"panic"
};
tracing::error!(panic = detail, "handler panicked");
HandlerError::new(ErrorCode::InternalServerError, "internal server error").into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handler_error_into_response_returns_problem_json() {
let err = HandlerError::new(ErrorCode::ResourceNotFound, "not found");
let resp = err.into_response();
assert_eq!(resp.status(), 404);
}
#[test]
fn handler_error_from_api_error() {
let api_err = ApiError::new(ErrorCode::InternalServerError, "oops");
let handler_err = HandlerError::from(api_err);
let resp = handler_err.into_response();
assert_eq!(resp.status(), 500);
}
#[test]
fn with_request_id_and_errors() {
let id = uuid::Uuid::now_v7();
let err = HandlerError::new(ErrorCode::ValidationFailed, "bad input")
.with_request_id(id)
.with_errors(vec![ValidationError {
field: "name".into(),
message: "required".into(),
rule: None,
}]);
let resp = err.into_response();
assert_eq!(resp.status(), 400);
}
#[test]
fn panic_handler_downcasts_string_payload() {
let payload: Box<dyn std::any::Any + Send + 'static> = Box::new("boom".to_string());
let resp = panic_handler(payload);
assert_eq!(resp.status(), 500);
}
#[test]
fn panic_handler_downcasts_static_str_payload() {
let payload: Box<dyn std::any::Any + Send + 'static> = Box::new("static boom");
let resp = panic_handler(payload);
assert_eq!(resp.status(), 500);
}
#[test]
fn panic_handler_handles_unknown_payload() {
let payload: Box<dyn std::any::Any + Send + 'static> = Box::new(42u32);
let resp = panic_handler(payload);
assert_eq!(resp.status(), 500);
}
#[test]
fn created_builds_201_with_envelope() {
let (status, body) = created("x").unwrap();
assert_eq!(status, axum::http::StatusCode::CREATED);
let json = serde_json::to_value(body.0).unwrap();
assert_eq!(json["data"], "x");
}
#[test]
fn ok_builds_200_with_envelope() {
let (status, body) = ok(42u32).unwrap();
assert_eq!(status, axum::http::StatusCode::OK);
let json = serde_json::to_value(body.0).unwrap();
assert_eq!(json["data"], 42);
}
#[test]
fn listed_wraps_paginated_response() {
use api_bones::{PaginatedResponse, pagination::PaginationParams};
let page: PaginatedResponse<u32> =
PaginatedResponse::new(vec![1, 2], 2, &PaginationParams::default());
let body = listed(page).unwrap();
let json = serde_json::to_value(body.0).unwrap();
assert_eq!(json["data"]["items"], serde_json::json!([1, 2]));
}
}