1pub use app_routes::{AppRoutes, ListRoutes};
69use axum::{
70 extract::FromRequest,
71 http::StatusCode,
72 response::{IntoResponse, Response},
73};
74use colored::Colorize;
75pub use routes::Routes;
76use serde::Serialize;
77
78#[cfg(feature = "with-db")]
79use crate::model::ModelError;
80use crate::{errors::Error, Result};
81
82mod app_routes;
83mod backtrace;
84mod describe;
85pub mod extractor;
86pub mod format;
87pub mod middleware;
88pub mod monitoring;
89mod routes;
90pub mod views;
91
92pub fn unauthorized<T: Into<String>, U>(msg: T) -> Result<U> {
115 Err(Error::Unauthorized(msg.into()))
116}
117
118pub fn bad_request<T: Into<String>, U>(msg: T) -> Result<U> {
124 Err(Error::BadRequest(msg.into()))
125}
126
127pub fn not_found<T>() -> Result<T> {
133 Err(Error::NotFound)
134}
135#[derive(Debug, Serialize)]
136pub struct ErrorDetail {
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub error: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub description: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub errors: Option<serde_json::Value>,
144}
145
146impl ErrorDetail {
147 #[must_use]
149 pub fn new<T1: Into<String> + AsRef<str>, T2: Into<String> + AsRef<str>>(
150 error: T1,
151 description: T2,
152 ) -> Self {
153 let description = (!description.as_ref().is_empty()).then(|| description.into());
154 Self {
155 error: Some(error.into()),
156 description,
157 errors: None,
158 }
159 }
160
161 #[must_use]
163 pub fn with_reason<T: Into<String>>(error: T) -> Self {
164 Self {
165 error: Some(error.into()),
166 description: None,
167 errors: None,
168 }
169 }
170}
171
172#[derive(Debug, FromRequest)]
173#[from_request(via(axum::Json), rejection(Error))]
174pub struct Json<T>(pub T);
175
176impl<T: Serialize> IntoResponse for Json<T> {
177 fn into_response(self) -> axum::response::Response {
178 axum::Json(self.0).into_response()
179 }
180}
181
182fn validation_error_response(
186 errors: &crate::validation::ModelValidationErrors,
187) -> (StatusCode, ErrorDetail) {
188 (
189 StatusCode::BAD_REQUEST,
190 ErrorDetail {
191 error: None,
192 description: None,
193 errors: Some(serde_json::to_value(&errors.errors).unwrap_or_default()),
194 },
195 )
196}
197
198impl IntoResponse for Error {
199 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
201 fn into_response(self) -> Response {
202 match &self {
203 Self::WithBacktrace {
204 inner,
205 backtrace: _,
206 } => {
207 tracing::error!(
208 error.msg = %inner,
209 error.details = ?inner,
210 "controller_error"
211 );
212 }
213 err => {
214 tracing::error!(
215 error.msg = %err,
216 error.details = ?err,
217 "controller_error"
218 );
219 }
220 }
221
222 let public_facing_error = match self {
223 Self::NotFound => (
224 StatusCode::NOT_FOUND,
225 ErrorDetail::new("not_found", "Resource was not found"),
226 ),
227 Self::Unauthorized(err) => {
228 tracing::warn!(err);
229 (
230 StatusCode::UNAUTHORIZED,
231 ErrorDetail::new(
232 "unauthorized",
233 "You do not have permission to access this resource",
234 ),
235 )
236 }
237 Self::CustomError(status_code, data) => (status_code, data),
238 Self::WithBacktrace { inner, backtrace } => {
239 println!("\n{}", inner.to_string().red().underline());
240 backtrace::print_backtrace(&backtrace).unwrap();
241 return (*inner).into_response();
245 }
246 Self::BadRequest(err) => (
247 StatusCode::BAD_REQUEST,
248 ErrorDetail::new("Bad Request", &err),
249 ),
250 Self::JsonRejection(err) => {
251 tracing::debug!(err = err.body_text(), "json rejection");
252 (err.status(), ErrorDetail::with_reason("Bad Request"))
253 }
254 Self::AxumFormRejection(err) => {
255 tracing::debug!(err = err.body_text(), "form rejection");
256 (err.status(), ErrorDetail::with_reason("Bad Request"))
257 }
258
259 Self::Validation(ref errors) => validation_error_response(errors),
260
261 #[cfg(feature = "with-db")]
262 Self::Model(ModelError::EntityNotFound) => (
263 StatusCode::NOT_FOUND,
264 ErrorDetail::new("not_found", "Resource was not found"),
265 ),
266 #[cfg(feature = "with-db")]
267 Self::Model(ModelError::EntityAlreadyExists) => (
268 StatusCode::CONFLICT,
269 ErrorDetail::new("conflict", "Resource already exists"),
270 ),
271 #[cfg(feature = "with-db")]
272 Self::Model(ModelError::Validation(ref errors)) => validation_error_response(errors),
273
274 Self::Message(_)
286 | Self::InternalServerError
287 | Self::QueueProviderMissing
288 | Self::TaskNotFound(_)
289 | Self::Scheduler(_)
290 | Self::Axum(_)
291 | Self::Tera(_)
292 | Self::JSON(_)
293 | Self::YAMLFile(_, _)
294 | Self::YAML(_)
295 | Self::EmailSender(_)
296 | Self::Smtp(_)
297 | Self::Worker(_)
298 | Self::IO(_)
299 | Self::ParseAddress(_)
300 | Self::InvalidHeaderValue(_)
301 | Self::InvalidHeaderName(_)
302 | Self::InvalidMethod(_)
303 | Self::Storage(_)
304 | Self::Cache(_)
305 | Self::VersionCheck(_)
306 | Self::Any(_) => (
307 StatusCode::INTERNAL_SERVER_ERROR,
308 ErrorDetail::new("internal_server_error", "Internal Server Error"),
309 ),
310
311 #[cfg(feature = "with-db")]
312 Self::DB(_) => (
313 StatusCode::INTERNAL_SERVER_ERROR,
314 ErrorDetail::new("internal_server_error", "Internal Server Error"),
315 ),
316
317 #[cfg(feature = "with-db")]
322 Self::Model(ModelError::DbErr(_) | ModelError::Any(_) | ModelError::Message(_)) => (
323 StatusCode::INTERNAL_SERVER_ERROR,
324 ErrorDetail::new("internal_server_error", "Internal Server Error"),
325 ),
326 #[cfg(all(feature = "with-db", feature = "auth"))]
327 Self::Model(ModelError::Jwt(_)) => (
328 StatusCode::INTERNAL_SERVER_ERROR,
329 ErrorDetail::new("internal_server_error", "Internal Server Error"),
330 ),
331
332 #[cfg(feature = "worker_redis")]
333 Self::Redis(_) => (
334 StatusCode::INTERNAL_SERVER_ERROR,
335 ErrorDetail::new("internal_server_error", "Internal Server Error"),
336 ),
337
338 #[cfg(feature = "worker")]
339 Self::Sqlx(_) => (
340 StatusCode::INTERNAL_SERVER_ERROR,
341 ErrorDetail::new("internal_server_error", "Internal Server Error"),
342 ),
343
344 #[cfg(debug_assertions)]
345 Self::Generators(_) => (
346 StatusCode::INTERNAL_SERVER_ERROR,
347 ErrorDetail::new("internal_server_error", "Internal Server Error"),
348 ),
349 };
350
351 (public_facing_error.0, Json(public_facing_error.1)).into_response()
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use axum::body::to_bytes;
358
359 use super::*;
360
361 async fn response_json(err: Error) -> (StatusCode, serde_json::Value) {
362 let response = err.into_response();
363 let status = response.status();
364 let body = to_bytes(response.into_body(), 1024 * 1024)
365 .await
366 .expect("failed to read response body");
367 let json: serde_json::Value =
368 serde_json::from_slice(&body).expect("response body is not valid JSON");
369 (status, json)
370 }
371
372 #[cfg(feature = "with-db")]
373 #[tokio::test]
374 async fn model_entity_not_found_maps_to_404() {
375 let (status, json) = response_json(Error::Model(ModelError::EntityNotFound)).await;
376
377 assert_eq!(status, StatusCode::NOT_FOUND);
378 assert_eq!(
379 json,
380 serde_json::json!({
381 "error": "not_found",
382 "description": "Resource was not found"
383 })
384 );
385 }
386
387 #[cfg(feature = "with-db")]
388 #[tokio::test]
389 async fn model_entity_already_exists_maps_to_409() {
390 let (status, json) = response_json(Error::Model(ModelError::EntityAlreadyExists)).await;
391
392 assert_eq!(status, StatusCode::CONFLICT);
393 assert_eq!(
394 json,
395 serde_json::json!({
396 "error": "conflict",
397 "description": "Resource already exists"
398 })
399 );
400 }
401
402 #[cfg(feature = "with-db")]
403 #[tokio::test]
404 async fn model_validation_maps_same_as_top_level_validation() {
405 use crate::validation::{ModelValidationErrors, ValidationError};
406 use std::collections::BTreeMap;
407
408 let mut errors: BTreeMap<String, Vec<ValidationError>> = BTreeMap::new();
409 errors.insert(
410 "username".to_string(),
411 vec![ValidationError {
412 code: "length".to_string(),
413 message: Some("username must be at least 3 characters".to_string()),
414 params: std::collections::HashMap::new(),
415 }],
416 );
417 let model_errors = ModelValidationErrors {
418 errors: errors.clone(),
419 };
420
421 let (model_status, model_json) =
422 response_json(Error::Model(ModelError::Validation(model_errors))).await;
423 let (top_level_status, top_level_json) =
424 response_json(Error::Validation(ModelValidationErrors { errors })).await;
425
426 assert_eq!(model_status, StatusCode::BAD_REQUEST);
427 assert_eq!(model_status, top_level_status);
428 assert_eq!(model_json, top_level_json);
429 }
430
431 #[tokio::test]
432 async fn axum_form_rejection_maps_to_4xx_not_500() {
433 #[derive(Debug, serde::Deserialize)]
434 struct Data {
435 #[allow(dead_code)]
436 email: String,
437 }
438
439 let request = axum::http::Request::builder()
440 .method(axum::http::Method::POST)
441 .uri("/")
442 .header(
443 axum::http::header::CONTENT_TYPE,
444 "application/x-www-form-urlencoded",
445 )
446 .body(axum::body::Body::from(""))
447 .unwrap();
448
449 let rejection = axum::extract::Form::<Data>::from_request(request, &())
450 .await
451 .expect_err("expected a form rejection for a missing required field");
452
453 let (status, json) = response_json(Error::AxumFormRejection(rejection)).await;
454
455 assert!(status.is_client_error(), "expected 4xx, got {status}");
456 assert_eq!(json, serde_json::json!({ "error": "Bad Request" }));
457 }
458
459 #[tokio::test]
460 async fn not_found_still_maps_to_404() {
461 let (status, json) = response_json(Error::NotFound).await;
462
463 assert_eq!(status, StatusCode::NOT_FOUND);
464 assert_eq!(
465 json,
466 serde_json::json!({
467 "error": "not_found",
468 "description": "Resource was not found"
469 })
470 );
471 }
472
473 #[tokio::test]
474 async fn infra_error_maps_to_500_with_standard_body() {
475 for err in [
476 Error::Message("boom".to_string()),
477 Error::InternalServerError,
478 ] {
479 let (status, json) = response_json(err).await;
480
481 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
482 assert_eq!(
483 json,
484 serde_json::json!({
485 "error": "internal_server_error",
486 "description": "Internal Server Error"
487 })
488 );
489 }
490 }
491
492 #[tokio::test]
493 async fn bad_request_still_maps_to_400() {
494 let (status, json) = response_json(Error::BadRequest("x".to_string())).await;
495
496 assert_eq!(status, StatusCode::BAD_REQUEST);
497 assert_eq!(
498 json,
499 serde_json::json!({
500 "error": "Bad Request",
501 "description": "x"
502 })
503 );
504 }
505}