1use std::{collections::BTreeMap, error::Error as StdError, fmt};
2
3use axum::{
4 Json,
5 extract::rejection::{JsonRejection, PathRejection, QueryRejection},
6 http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::RETRY_AFTER},
7 response::{IntoResponse, Response},
8};
9use serde_json::Value;
10
11use crate::{ErrorBody, ErrorEnvelope, middleware::current_request_id};
12
13pub struct ApiError {
19 status: StatusCode,
20 code: String,
21 message: String,
22 details: BTreeMap<String, Value>,
23 headers: HeaderMap,
24 cause: Option<Box<dyn StdError + Send + Sync>>,
25}
26
27impl ApiError {
28 pub fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
33 let code = code.into();
34 assert!(
35 is_valid_error_code(&code),
36 "API error codes must be non-empty snake_case identifiers"
37 );
38 Self {
39 status,
40 code,
41 message: message.into(),
42 details: BTreeMap::new(),
43 headers: HeaderMap::new(),
44 cause: None,
45 }
46 }
47
48 #[must_use]
50 pub fn with_details(mut self, details: BTreeMap<String, Value>) -> Self {
51 self.details = details;
52 self
53 }
54
55 #[must_use]
60 pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
61 self.headers.insert(name, value);
62 self
63 }
64
65 #[must_use]
67 pub fn with_retry_after(self, seconds: u64) -> Self {
68 self.with_header(
69 RETRY_AFTER,
70 HeaderValue::from_str(&seconds.to_string())
71 .expect("unsigned integers are valid header values"),
72 )
73 }
74
75 pub fn bad_request(message: impl Into<String>) -> Self {
77 Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
78 }
79
80 pub fn validation(details: BTreeMap<String, Value>) -> Self {
82 Self::new(
83 StatusCode::BAD_REQUEST,
84 "validation_failed",
85 "The request is invalid",
86 )
87 .with_details(details)
88 }
89
90 pub fn validation_field(field: impl Into<String>, message: impl Into<String>) -> Self {
95 Self::validation(BTreeMap::from([(
96 field.into(),
97 Value::String(message.into()),
98 )]))
99 }
100
101 pub fn validation_fields<K, M, I>(fields: I) -> Self
106 where
107 K: Into<String>,
108 M: Into<String>,
109 I: IntoIterator<Item = (K, M)>,
110 {
111 Self::validation(
112 fields
113 .into_iter()
114 .map(|(field, message)| (field.into(), Value::String(message.into())))
115 .collect(),
116 )
117 }
118
119 pub fn unauthorized(message: impl Into<String>) -> Self {
121 Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
122 }
123
124 pub fn unauthenticated() -> Self {
126 Self::new(
127 StatusCode::UNAUTHORIZED,
128 "unauthenticated",
129 "Authentication is required",
130 )
131 }
132
133 pub fn forbidden(message: impl Into<String>) -> Self {
135 Self::new(StatusCode::FORBIDDEN, "forbidden", message)
136 }
137
138 pub fn permission_denied() -> Self {
140 Self::new(
141 StatusCode::FORBIDDEN,
142 "permission_denied",
143 "Permission denied",
144 )
145 }
146
147 pub fn not_found(message: impl Into<String>) -> Self {
149 Self::new(StatusCode::NOT_FOUND, "not_found", message)
150 }
151
152 pub fn conflict(message: impl Into<String>) -> Self {
154 Self::new(StatusCode::CONFLICT, "conflict", message)
155 }
156
157 pub fn method_not_allowed() -> Self {
159 Self::new(
160 StatusCode::METHOD_NOT_ALLOWED,
161 "method_not_allowed",
162 "Method not allowed",
163 )
164 }
165
166 pub fn rate_limited() -> Self {
168 Self::new(
169 StatusCode::TOO_MANY_REQUESTS,
170 "rate_limited",
171 "Too many requests",
172 )
173 }
174
175 pub fn internal(error: impl StdError + Send + Sync + 'static) -> Self {
177 let mut api_error = Self::internal_without_cause();
178 api_error.cause = Some(Box::new(error));
179 api_error
180 }
181
182 pub(crate) fn timeout() -> Self {
183 Self::new(
184 StatusCode::GATEWAY_TIMEOUT,
185 "request_timeout",
186 "The request timed out",
187 )
188 }
189
190 pub(crate) fn payload_too_large() -> Self {
191 Self::new(
192 StatusCode::PAYLOAD_TOO_LARGE,
193 "payload_too_large",
194 "The request body is too large",
195 )
196 }
197
198 pub(crate) fn json_rejection(code: impl Into<String>) -> Self {
199 Self::new(StatusCode::BAD_REQUEST, code, "The request is invalid").with_details(
200 BTreeMap::from([(
201 "body".to_owned(),
202 Value::String("must be valid JSON matching the request schema".to_owned()),
203 )]),
204 )
205 }
206
207 pub(crate) fn query_rejection() -> Self {
208 Self::validation_field("query", "must contain valid query parameters")
209 }
210
211 pub(crate) fn internal_without_cause() -> Self {
212 Self::new(
213 StatusCode::INTERNAL_SERVER_ERROR,
214 "internal",
215 "An internal error occurred",
216 )
217 }
218
219 #[must_use]
221 pub const fn status(&self) -> StatusCode {
222 self.status
223 }
224
225 #[must_use]
227 pub fn code(&self) -> &str {
228 &self.code
229 }
230}
231
232impl From<JsonRejection> for ApiError {
233 fn from(_rejection: JsonRejection) -> Self {
234 Self::json_rejection("validation_failed")
235 }
236}
237
238impl From<PathRejection> for ApiError {
239 fn from(_rejection: PathRejection) -> Self {
240 Self::validation(BTreeMap::from([(
241 "path".to_owned(),
242 Value::String("must contain valid route parameters".to_owned()),
243 )]))
244 }
245}
246
247impl From<QueryRejection> for ApiError {
248 fn from(_rejection: QueryRejection) -> Self {
249 Self::query_rejection()
250 }
251}
252
253pub(crate) fn is_valid_error_code(code: &str) -> bool {
254 !code.is_empty()
255 && !code.starts_with('_')
256 && !code.ends_with('_')
257 && !code.contains("__")
258 && code
259 .bytes()
260 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
261}
262
263impl fmt::Debug for ApiError {
264 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
265 formatter
266 .debug_struct("ApiError")
267 .field("status", &self.status)
268 .field("code", &self.code)
269 .field("message", &self.message)
270 .field("details", &self.details)
271 .field("headers", &self.headers)
272 .field("has_cause", &self.cause.is_some())
273 .finish()
274 }
275}
276
277impl fmt::Display for ApiError {
278 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
279 write!(formatter, "{} ({})", self.message, self.code)
280 }
281}
282
283impl StdError for ApiError {
284 fn source(&self) -> Option<&(dyn StdError + 'static)> {
285 self.cause
286 .as_deref()
287 .map(|cause| cause as &(dyn StdError + 'static))
288 }
289}
290
291impl IntoResponse for ApiError {
292 fn into_response(self) -> Response {
293 let request_id = current_request_id();
294 if let Some(cause) = self.cause.as_deref() {
295 tracing::error!(error = %cause, "request failed with an internal error");
296 }
297
298 let envelope = ErrorEnvelope {
299 error: ErrorBody {
300 code: self.code,
301 message: self.message,
302 request_id: request_id.to_string(),
303 details: self.details,
304 },
305 };
306 let mut response = (self.status, Json(envelope)).into_response();
307 response.headers_mut().extend(self.headers);
308 response.headers_mut().insert(
309 crate::X_REQUEST_ID,
310 request_id
311 .header_value()
312 .expect("request IDs originate from a valid header or UUID"),
313 );
314 response
315 }
316}