1use axum::Json;
2use axum::http::StatusCode;
3use axum::response::{IntoResponse, Response};
4use serde::Serialize;
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8#[non_exhaustive]
9pub enum AppError {
10 #[error("not found: {0}")]
11 NotFound(String),
12 #[error("bad request: {0}")]
13 BadRequest(String),
14 #[error("validation error: {0}")]
15 ValidationError(String),
16 #[error("unauthorized: {0}")]
17 Unauthorized(String),
18 #[error("forbidden: {0}")]
19 Forbidden(String),
20 #[error("conflict: {0}")]
21 Conflict(String),
22 #[error("not implemented: {0}")]
23 NotImplemented(String),
24 #[error("internal error: {0}")]
25 InternalError(String),
26}
27
28#[derive(Debug, Serialize)]
29pub struct ErrorResponse {
30 pub error: &'static str,
31 pub message: String,
32}
33
34impl AppError {
35 pub fn kind(&self) -> &'static str {
36 match self {
37 Self::NotFound(_) => "not_found",
38 Self::BadRequest(_) => "bad_request",
39 Self::ValidationError(_) => "validation_error",
40 Self::Unauthorized(_) => "unauthorized",
41 Self::Forbidden(_) => "forbidden",
42 Self::Conflict(_) => "conflict",
43 Self::NotImplemented(_) => "not_implemented",
44 Self::InternalError(_) => "internal_error",
45 }
46 }
47
48 fn status(&self) -> StatusCode {
49 match self {
50 Self::NotFound(_) => StatusCode::NOT_FOUND,
51 Self::BadRequest(_) | Self::ValidationError(_) => StatusCode::BAD_REQUEST,
52 Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
53 Self::Forbidden(_) => StatusCode::FORBIDDEN,
54 Self::Conflict(_) => StatusCode::CONFLICT,
55 Self::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED,
56 Self::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
57 }
58 }
59
60 fn message(&self) -> String {
61 match self {
62 Self::NotFound(m)
63 | Self::BadRequest(m)
64 | Self::ValidationError(m)
65 | Self::Unauthorized(m)
66 | Self::Forbidden(m)
67 | Self::Conflict(m)
68 | Self::NotImplemented(m)
69 | Self::InternalError(m) => m.clone(),
70 }
71 }
72}
73
74impl IntoResponse for AppError {
75 fn into_response(self) -> Response {
76 let status = self.status();
77 let body = ErrorResponse {
78 error: self.kind(),
79 message: self.message(),
80 };
81 (status, Json(body)).into_response()
82 }
83}
84
85#[cfg(test)]
86#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn status_codes_map_correctly() {
92 assert_eq!(
93 AppError::NotFound("x".into()).status(),
94 StatusCode::NOT_FOUND
95 );
96 assert_eq!(
97 AppError::BadRequest("x".into()).status(),
98 StatusCode::BAD_REQUEST
99 );
100 assert_eq!(
101 AppError::ValidationError("x".into()).status(),
102 StatusCode::BAD_REQUEST
103 );
104 assert_eq!(
105 AppError::Unauthorized("x".into()).status(),
106 StatusCode::UNAUTHORIZED
107 );
108 assert_eq!(
109 AppError::Forbidden("x".into()).status(),
110 StatusCode::FORBIDDEN
111 );
112 assert_eq!(
113 AppError::Conflict("x".into()).status(),
114 StatusCode::CONFLICT
115 );
116 assert_eq!(
117 AppError::NotImplemented("x".into()).status(),
118 StatusCode::NOT_IMPLEMENTED
119 );
120 assert_eq!(
121 AppError::InternalError("x".into()).status(),
122 StatusCode::INTERNAL_SERVER_ERROR
123 );
124 }
125
126 #[test]
127 fn kind_strings_are_stable() {
128 assert_eq!(AppError::NotFound("x".into()).kind(), "not_found");
129 assert_eq!(
130 AppError::NotImplemented("x".into()).kind(),
131 "not_implemented"
132 );
133 }
134
135 #[test]
136 fn message_round_trip() {
137 let m = "boom";
138 let err = AppError::InternalError(m.into());
139 assert_eq!(err.message(), m);
140 assert!(format!("{err}").contains(m));
141 }
142}