Skip to main content

loco_rs/controller/
mod.rs

1//! Manage web server routing
2//!
3//! # Example
4//!
5//! This example you can adding custom routes into your application by
6//! implementing routes trait from [`crate::app::Hooks`] and adding your
7//! endpoints to your application
8//!
9//! ```rust
10//! use async_trait::async_trait;
11//! use loco_rs::{
12//!    app::{AppContext, Hooks},
13//!    boot::{create_app, BootResult, StartMode},
14//!    config::Config,
15//!    controller::AppRoutes,
16//!    prelude::*,
17//!    task::Tasks,
18//!    environment::Environment,
19//!    Result,
20//! };
21//! use sea_orm::DatabaseConnection;
22//! use std::path::Path;
23//!
24//! /// this code block should be taken from the sea_orm migration model.
25//! pub struct App;
26//! pub use sea_orm_migration::prelude::*;
27//! pub struct Migrator;
28//! #[async_trait::async_trait]
29//! impl MigratorTrait for Migrator {
30//!     fn migrations() -> Vec<Box<dyn MigrationTrait>> {
31//!         vec![]
32//!     }
33//! }
34//!
35//! #[async_trait]
36//! impl Hooks for App {
37//!
38//!    fn app_name() -> &'static str {
39//!        env!("CARGO_CRATE_NAME")
40//!    }
41//!
42//!     fn routes(ctx: &AppContext) -> AppRoutes {
43//!         AppRoutes::with_default_routes()
44//!             // .add_route(controllers::notes::routes())
45//!     }
46//!
47//!     async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result<BootResult>{
48//!          create_app::<Self, Migrator>(mode, environment, config).await
49//!     }
50//!
51//!     async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> {
52//!         Ok(())
53//!     }
54//!
55//!
56//!     fn register_tasks(tasks: &mut Tasks) {}
57//!
58//!     async fn truncate(_ctx: &AppContext) -> Result<()> {
59//!         Ok(())
60//!     }
61//!
62//!     async fn seed(_ctx: &AppContext, base: &Path) -> Result<()> {
63//!         Ok(())
64//!     }
65//! }
66//! ```
67
68pub 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
92/// Create an unauthorized error with a specified message.
93///
94/// This function is used to generate an `Error::Unauthorized` variant with a
95/// custom message.
96///
97/// # Errors
98///
99/// returns unauthorized enum
100///
101/// # Example
102///
103/// ```rust
104/// use loco_rs::prelude::*;
105///
106/// async fn login() -> Result<Response> {
107///     let valid = false;
108///     if !valid {
109///         return unauthorized("unauthorized access");
110///     }
111///     format::json(())
112/// }
113/// ````
114pub fn unauthorized<T: Into<String>, U>(msg: T) -> Result<U> {
115    Err(Error::Unauthorized(msg.into()))
116}
117
118/// Return a bad request with a message
119///
120/// # Errors
121///
122/// This function will return an error result
123pub fn bad_request<T: Into<String>, U>(msg: T) -> Result<U> {
124    Err(Error::BadRequest(msg.into()))
125}
126
127/// return not found status code
128///
129/// # Errors
130/// Currently this function doesn't return any error. this is for feature
131/// functionality
132pub fn not_found<T>() -> Result<T> {
133    Err(Error::NotFound)
134}
135#[derive(Debug, Serialize)]
136/// Structure representing details about an error.
137pub 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    /// Create a new `ErrorDetail` with the specified error and description.
148    #[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    /// Create an `ErrorDetail` with only an error reason and no description.
162    #[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
182/// Build the `(StatusCode, ErrorDetail)` pair for a validation error, shared
183/// by [`Error::Validation`] and <code>[Error::Model]([ModelError::Validation])</code>
184/// so both report the same shape.
185fn 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    /// Convert an `Error` into an HTTP response.
200    #[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                // Delegate to the wrapped error's own response so the real HTTP
242                // status is preserved (e.g. internal errors stay 500) instead of
243                // being flattened to 400 whenever a backtrace was captured.
244                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            // --- Internal / infrastructure errors: deliberately no `_` arm.
275            //
276            // `Error` is `#[non_exhaustive]` for downstream crates, but this
277            // match lives inside `loco_rs` itself, where an exhaustive match
278            // over a local enum is allowed. Keeping it exhaustive (instead of
279            // a trailing wildcard) means that adding a new `Error` variant
280            // anywhere in the crate is a compile error here until someone
281            // deliberately decides which HTTP status it should map to, rather
282            // than silently defaulting to 500. Every variant below is an
283            // internal/infrastructure error, so they are all mapped to the
284            // same generic 500 response.
285            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            // Other `ModelError` variants are internal/infrastructure errors
318            // (DB, generic `Any`, free-form `Message`, and, when `auth`
319            // is enabled, `Jwt`) and are collapsed to 500 like their
320            // top-level counterparts.
321            #[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}