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: Option<Box<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: None,
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
62 .get_or_insert_with(Default::default)
63 .insert(name, value);
64 self
65 }
66
67 #[must_use]
69 pub fn with_retry_after(self, seconds: u64) -> Self {
70 self.with_header(
71 RETRY_AFTER,
72 HeaderValue::from_str(&seconds.to_string())
73 .expect("unsigned integers are valid header values"),
74 )
75 }
76
77 pub fn bad_request(message: impl Into<String>) -> Self {
79 Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
80 }
81
82 pub fn validation(details: BTreeMap<String, Value>) -> Self {
84 Self::new(
85 StatusCode::BAD_REQUEST,
86 "validation_failed",
87 "The request is invalid",
88 )
89 .with_details(details)
90 }
91
92 pub fn validation_field(field: impl Into<String>, message: impl Into<String>) -> Self {
97 Self::validation(BTreeMap::from([(
98 field.into(),
99 Value::String(message.into()),
100 )]))
101 }
102
103 pub fn validation_fields<K, M, I>(fields: I) -> Self
108 where
109 K: Into<String>,
110 M: Into<String>,
111 I: IntoIterator<Item = (K, M)>,
112 {
113 Self::validation(
114 fields
115 .into_iter()
116 .map(|(field, message)| (field.into(), Value::String(message.into())))
117 .collect(),
118 )
119 }
120
121 pub fn unauthorized(message: impl Into<String>) -> Self {
123 Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
124 }
125
126 pub fn unauthenticated() -> Self {
128 Self::new(
129 StatusCode::UNAUTHORIZED,
130 "unauthenticated",
131 "Authentication is required",
132 )
133 }
134
135 pub fn forbidden(message: impl Into<String>) -> Self {
137 Self::new(StatusCode::FORBIDDEN, "forbidden", message)
138 }
139
140 pub fn permission_denied() -> Self {
142 Self::new(
143 StatusCode::FORBIDDEN,
144 "permission_denied",
145 "Permission denied",
146 )
147 }
148
149 pub fn not_found(message: impl Into<String>) -> Self {
151 Self::new(StatusCode::NOT_FOUND, "not_found", message)
152 }
153
154 pub fn conflict(message: impl Into<String>) -> Self {
156 Self::new(StatusCode::CONFLICT, "conflict", message)
157 }
158
159 pub fn method_not_allowed() -> Self {
161 Self::new(
162 StatusCode::METHOD_NOT_ALLOWED,
163 "method_not_allowed",
164 "Method not allowed",
165 )
166 }
167
168 pub fn rate_limited() -> Self {
170 Self::new(
171 StatusCode::TOO_MANY_REQUESTS,
172 "rate_limited",
173 "Too many requests",
174 )
175 }
176
177 pub fn internal(error: impl StdError + Send + Sync + 'static) -> Self {
179 let mut api_error = Self::internal_without_cause();
180 api_error.cause = Some(Box::new(error));
181 api_error
182 }
183
184 pub(crate) fn timeout() -> Self {
185 Self::new(
186 StatusCode::GATEWAY_TIMEOUT,
187 "request_timeout",
188 "The request timed out",
189 )
190 }
191
192 pub(crate) fn payload_too_large() -> Self {
193 Self::new(
194 StatusCode::PAYLOAD_TOO_LARGE,
195 "payload_too_large",
196 "The request body is too large",
197 )
198 }
199
200 pub(crate) fn json_rejection(code: impl Into<String>) -> Self {
201 Self::new(StatusCode::BAD_REQUEST, code, "The request is invalid").with_details(
202 BTreeMap::from([(
203 "body".to_owned(),
204 Value::String("must be valid JSON matching the request schema".to_owned()),
205 )]),
206 )
207 }
208
209 pub(crate) fn query_rejection() -> Self {
210 Self::validation_field("query", "must contain valid query parameters")
211 }
212
213 pub(crate) fn internal_without_cause() -> Self {
214 Self::new(
215 StatusCode::INTERNAL_SERVER_ERROR,
216 "internal",
217 "An internal error occurred",
218 )
219 }
220
221 #[must_use]
223 pub const fn status(&self) -> StatusCode {
224 self.status
225 }
226
227 #[must_use]
229 pub fn code(&self) -> &str {
230 &self.code
231 }
232}
233
234impl From<JsonRejection> for ApiError {
235 fn from(_rejection: JsonRejection) -> Self {
236 Self::json_rejection("validation_failed")
237 }
238}
239
240impl From<PathRejection> for ApiError {
241 fn from(_rejection: PathRejection) -> Self {
242 Self::validation(BTreeMap::from([(
243 "path".to_owned(),
244 Value::String("must contain valid route parameters".to_owned()),
245 )]))
246 }
247}
248
249impl From<QueryRejection> for ApiError {
250 fn from(_rejection: QueryRejection) -> Self {
251 Self::query_rejection()
252 }
253}
254
255pub(crate) fn is_valid_error_code(code: &str) -> bool {
256 !code.is_empty()
257 && !code.starts_with('_')
258 && !code.ends_with('_')
259 && !code.contains("__")
260 && code
261 .bytes()
262 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
263}
264
265impl fmt::Debug for ApiError {
266 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267 formatter
268 .debug_struct("ApiError")
269 .field("status", &self.status)
270 .field("code", &self.code)
271 .field("message", &self.message)
272 .field("details", &self.details)
273 .field("headers", &self.headers)
274 .field("has_cause", &self.cause.is_some())
275 .finish()
276 }
277}
278
279impl fmt::Display for ApiError {
280 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
281 write!(formatter, "{} ({})", self.message, self.code)
282 }
283}
284
285impl StdError for ApiError {
286 fn source(&self) -> Option<&(dyn StdError + 'static)> {
287 self.cause
288 .as_deref()
289 .map(|cause| cause as &(dyn StdError + 'static))
290 }
291}
292
293impl IntoResponse for ApiError {
294 fn into_response(self) -> Response {
295 let request_id = current_request_id();
296 if let Some(cause) = self.cause.as_deref() {
297 tracing::error!(error = %cause, "request failed with an internal error");
298 }
299
300 let envelope = ErrorEnvelope {
301 error: ErrorBody {
302 code: self.code,
303 message: self.message,
304 request_id: request_id.to_string(),
305 details: self.details,
306 },
307 };
308 let mut response = (self.status, Json(envelope)).into_response();
309 if let Some(headers) = self.headers {
310 response.headers_mut().extend(*headers);
311 }
312 response.headers_mut().insert(
313 crate::X_REQUEST_ID,
314 request_id
315 .header_value()
316 .expect("request IDs originate from a valid header or UUID"),
317 );
318 response
319 }
320}