├── example ├── src │ ├── tasks │ │ └── mod.rs │ ├── initializers │ │ └── mod.rs │ ├── views │ │ ├── mod.rs │ │ └── auth.rs │ ├── mailers │ │ ├── mod.rs │ │ ├── auth │ │ │ ├── welcome │ │ │ │ ├── subject.t │ │ │ │ ├── text.t │ │ │ │ └── html.t │ │ │ ├── magic_link │ │ │ │ ├── subject.t │ │ │ │ ├── text.t │ │ │ │ └── html.t │ │ │ └── forgot │ │ │ │ ├── subject.t │ │ │ │ ├── text.t │ │ │ │ └── html.t │ │ └── auth.rs │ ├── workers │ │ ├── mod.rs │ │ └── downloader.rs │ ├── models │ │ ├── mod.rs │ │ ├── _entities │ │ │ ├── mod.rs │ │ │ ├── prelude.rs │ │ │ └── users.rs │ │ └── users.rs │ ├── controllers │ │ ├── mod.rs │ │ ├── album.rs │ │ └── auth.rs │ ├── lib.rs │ ├── bin │ │ └── main.rs │ ├── fixtures │ │ └── users.yaml │ └── app.rs ├── tests │ ├── tasks │ │ └── mod.rs │ ├── workers │ │ └── mod.rs │ ├── models │ │ ├── mod.rs │ │ ├── snapshots │ │ │ ├── can_find_by_email@users-2.snap │ │ │ ├── can_find_by_pid@users-2.snap │ │ │ ├── handle_create_with_password_with_duplicate@users.snap │ │ │ ├── can_validate_model@users.snap │ │ │ ├── can_create_with_password@users.snap │ │ │ ├── can_find_by_pid@users.snap │ │ │ └── can_find_by_email@users.snap │ │ └── users.rs │ ├── mod.rs │ └── requests │ │ ├── mod.rs │ │ ├── snapshots │ │ ├── can_reset_password@auth_request.snap │ │ ├── can_login_without_verify@auth_request.snap │ │ ├── can_auth_with_magic_link@auth_request.snap │ │ ├── can_get_current_user@auth_request.snap │ │ ├── login_with_valid_password@auth_request.snap │ │ ├── login_with_invalid_password@auth_request.snap │ │ └── can_register@auth_request.snap │ │ ├── album.rs │ │ ├── prepare_data.rs │ │ └── auth.rs ├── .rustfmt.toml ├── .cargo │ └── config.toml ├── .gitignore ├── migration │ ├── src │ │ ├── lib.rs │ │ └── m20220101_000001_users.rs │ └── Cargo.toml ├── examples │ └── playground.rs ├── Cargo.toml └── config │ ├── test.yaml │ └── development.yaml ├── tests ├── ui │ ├── mod.rs │ ├── snapshots │ │ ├── r#mod__ui__reqeust__[__swagger__].snap │ │ ├── r#mod__ui__reqeust__[__redoc__openapi.yaml].snap │ │ ├── r#mod__ui__reqeust__[__scalar__openapi.yaml].snap │ │ ├── r#mod__ui__reqeust__[__swagger__openapi.yaml].snap │ │ ├── r#mod__ui__reqeust__[__scalar].snap │ │ ├── r#mod__ui__reqeust__[__redoc].snap │ │ ├── r#mod__ui__reqeust__[__redoc__openapi.json].snap │ │ ├── r#mod__ui__reqeust__[__scalar__openapi.json].snap │ │ └── r#mod__ui__reqeust__[__swagger__openapi.json].snap │ └── reqeust.rs └── mod.rs ├── .gitignore ├── src ├── prelude.rs ├── utils.rs ├── openapi.rs ├── lib.rs ├── auth.rs └── config.rs ├── Cargo.toml ├── .github └── workflows │ ├── example.yaml │ └── ci.yaml └── README.md /example/src/tasks/mod.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /example/tests/tasks/mod.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /example/tests/workers/mod.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /example/src/initializers/mod.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod reqeust; 2 | -------------------------------------------------------------------------------- /example/src/views/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | -------------------------------------------------------------------------------- /example/tests/models/mod.rs: -------------------------------------------------------------------------------- 1 | mod users; 2 | -------------------------------------------------------------------------------- /example/src/mailers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | -------------------------------------------------------------------------------- /example/src/workers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod downloader; 2 | -------------------------------------------------------------------------------- /tests/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "full")] 2 | mod ui; 3 | -------------------------------------------------------------------------------- /example/src/mailers/auth/welcome/subject.t: -------------------------------------------------------------------------------- 1 | Welcome {{name}} 2 | -------------------------------------------------------------------------------- /example/src/mailers/auth/magic_link/subject.t: -------------------------------------------------------------------------------- 1 | Magic link example 2 | -------------------------------------------------------------------------------- /example/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod _entities; 2 | pub mod users; 3 | -------------------------------------------------------------------------------- /example/src/controllers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | 3 | pub mod album; 4 | -------------------------------------------------------------------------------- /example/src/mailers/auth/forgot/subject.t: -------------------------------------------------------------------------------- 1 | Your reset password link 2 | -------------------------------------------------------------------------------- /example/.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | use_small_heuristics = "Default" 3 | -------------------------------------------------------------------------------- /example/tests/mod.rs: -------------------------------------------------------------------------------- 1 | mod models; 2 | mod requests; 3 | mod tasks; 4 | mod workers; 5 | -------------------------------------------------------------------------------- /example/tests/requests/mod.rs: -------------------------------------------------------------------------------- 1 | mod auth; 2 | mod prepare_data; 3 | 4 | pub mod album; 5 | -------------------------------------------------------------------------------- /example/src/mailers/auth/magic_link/text.t: -------------------------------------------------------------------------------- 1 | Magic link with this link: 2 | {{host}}/api/auth/magic-link/{{token}} -------------------------------------------------------------------------------- /example/src/mailers/auth/forgot/text.t: -------------------------------------------------------------------------------- 1 | Reset your password with this link: 2 | 3 | http://localhost/reset#{{resetToken}} 4 | -------------------------------------------------------------------------------- /example/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | loco = "run --" 3 | loco-tool = "run --" 4 | 5 | playground = "run --example playground" 6 | -------------------------------------------------------------------------------- /example/src/models/_entities/mod.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | pub mod prelude; 4 | pub mod users; 5 | -------------------------------------------------------------------------------- /example/src/models/_entities/prelude.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | pub use super::users::Entity as Users; 3 | -------------------------------------------------------------------------------- /example/src/mailers/auth/welcome/text.t: -------------------------------------------------------------------------------- 1 | Welcome {{name}}, you can now log in. 2 | Verify your account with the link below: 3 | 4 | {{domain}}/api/auth/verify/{{verifyToken}} 5 | -------------------------------------------------------------------------------- /example/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod controllers; 3 | pub mod initializers; 4 | pub mod mailers; 5 | pub mod models; 6 | pub mod tasks; 7 | pub mod views; 8 | pub mod workers; 9 | -------------------------------------------------------------------------------- /example/tests/models/snapshots/can_find_by_email@users-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/models/users.rs 3 | expression: non_existing_user_results 4 | --- 5 | Err( 6 | EntityNotFound, 7 | ) 8 | -------------------------------------------------------------------------------- /example/tests/models/snapshots/can_find_by_pid@users-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/models/users.rs 3 | expression: non_existing_user_results 4 | --- 5 | Err( 6 | EntityNotFound, 7 | ) 8 | -------------------------------------------------------------------------------- /example/tests/requests/snapshots/can_reset_password@auth_request.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/requests/auth.rs 3 | expression: "(reset_response.status_code(), reset_response.text())" 4 | --- 5 | "null" -------------------------------------------------------------------------------- /example/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/models/users.rs 3 | expression: new_user 4 | --- 5 | Err( 6 | EntityAlreadyExists, 7 | ) 8 | -------------------------------------------------------------------------------- /example/src/mailers/auth/magic_link/html.t: -------------------------------------------------------------------------------- 1 | ; 2 | 3 |

Magic link example:

4 | 5 | Verify Your Account 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/src/bin/main.rs: -------------------------------------------------------------------------------- 1 | use loco_openapi_example::app::App; 2 | use loco_rs::cli; 3 | use migration::Migrator; 4 | 5 | #[tokio::main] 6 | async fn main() -> loco_rs::Result<()> { 7 | cli::main::().await 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # include cargo lock 7 | Cargo.lock 8 | example/loco-openapi_test.sqlite-shm 9 | example/loco-openapi_test.sqlite-wal 10 | -------------------------------------------------------------------------------- /example/tests/requests/snapshots/can_login_without_verify@auth_request.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/requests/auth.rs 3 | expression: login_response.text() 4 | --- 5 | "{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}" 6 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | pub use super::auth::{set_jwt_location, SecurityAddon}; 2 | pub use super::openapi::openapi; 3 | pub use utoipa; 4 | pub use utoipa::{path, schema, OpenApi, ToSchema}; 5 | pub use utoipa_axum::{router::OpenApiRouter, routes}; 6 | -------------------------------------------------------------------------------- /example/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/requests/auth.rs 3 | expression: magic_link_response.text() 4 | --- 5 | "{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}" 6 | -------------------------------------------------------------------------------- /example/tests/requests/snapshots/can_get_current_user@auth_request.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/requests/auth.rs 3 | expression: "(response.status_code(), response.text())" 4 | --- 5 | ( 6 | 200, 7 | "{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}", 8 | ) 9 | -------------------------------------------------------------------------------- /example/tests/requests/snapshots/login_with_valid_password@auth_request.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/requests/auth.rs 3 | expression: "(response.status_code(), response.text())" 4 | --- 5 | ( 6 | 200, 7 | "{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":true}", 8 | ) 9 | -------------------------------------------------------------------------------- /example/tests/requests/snapshots/login_with_invalid_password@auth_request.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/requests/auth.rs 3 | expression: "(response.status_code(), response.text())" 4 | --- 5 | ( 6 | 401, 7 | "{\"error\":\"unauthorized\",\"description\":\"You do not have permission to access this resource\"}", 8 | ) 9 | -------------------------------------------------------------------------------- /example/tests/models/snapshots/can_validate_model@users.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/models/users.rs 3 | expression: res 4 | snapshot_kind: text 5 | --- 6 | Err( 7 | Custom( 8 | "{\"email\":[{\"code\":\"email\",\"message\":\"invalid email\"}],\"name\":[{\"code\":\"length\",\"message\":\"Name must be at least 2 characters long.\"}]}", 9 | ), 10 | ) 11 | -------------------------------------------------------------------------------- /example/src/mailers/auth/forgot/html.t: -------------------------------------------------------------------------------- 1 | ; 2 | 3 | 4 | Hey {{name}}, 5 | Forgot your password? No worries! You can reset it by clicking the link below: 6 | Reset Your Password 7 | If you didn't request a password reset, please ignore this email. 8 | Best regards,
The Loco Team
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /example/src/mailers/auth/welcome/html.t: -------------------------------------------------------------------------------- 1 | ; 2 | 3 | 4 | Dear {{name}}, 5 | Welcome to Loco! You can now log in to your account. 6 | Before you get started, please verify your account by clicking the link below: 7 | 8 | Verify Your Account 9 | 10 |

Best regards,
The Loco Team

11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | **/config/local.yaml 2 | **/config/*.local.yaml 3 | **/config/production.yaml 4 | 5 | # Generated by Cargo 6 | # will have compiled files and executables 7 | debug/ 8 | target/ 9 | 10 | Cargo.lock 11 | 12 | # These are backup files generated by rustfmt 13 | **/*.rs.bk 14 | 15 | # MSVC Windows builds of rustc generate these, which store debugging information 16 | *.pdb 17 | 18 | *.sqlite -------------------------------------------------------------------------------- /example/tests/requests/album.rs: -------------------------------------------------------------------------------- 1 | use loco_openapi_example::app::App; 2 | use loco_rs::testing::prelude::*; 3 | use serial_test::serial; 4 | 5 | #[tokio::test] 6 | #[serial] 7 | async fn can_get_albums() { 8 | request::(|request, _ctx| async move { 9 | let res = request.get("/api/albums/").await; 10 | assert_eq!(res.status_code(), 200); 11 | 12 | // you can assert content like this: 13 | // assert_eq!(res.text(), "content"); 14 | }) 15 | .await; 16 | } 17 | -------------------------------------------------------------------------------- /example/migration/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(elided_lifetimes_in_paths)] 2 | #![allow(clippy::wildcard_imports)] 3 | pub use sea_orm_migration::prelude::*; 4 | mod m20220101_000001_users; 5 | 6 | pub struct Migrator; 7 | 8 | #[async_trait::async_trait] 9 | impl MigratorTrait for Migrator { 10 | fn migrations() -> Vec> { 11 | vec![ 12 | Box::new(m20220101_000001_users::Migration), 13 | // inject-above (do not remove this comment) 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /example/src/workers/downloader.rs: -------------------------------------------------------------------------------- 1 | use loco_rs::prelude::*; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | pub struct DownloadWorker { 5 | pub ctx: AppContext, 6 | } 7 | 8 | #[derive(Deserialize, Debug, Serialize)] 9 | pub struct DownloadWorkerArgs { 10 | pub user_guid: String, 11 | } 12 | 13 | #[async_trait] 14 | impl BackgroundWorker for DownloadWorker { 15 | fn build(ctx: &AppContext) -> Self { 16 | Self { ctx: ctx.clone() } 17 | } 18 | async fn perform(&self, _args: DownloadWorkerArgs) -> Result<()> { 19 | // TODO: Some actual work goes here... 20 | 21 | Ok(()) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/tests/models/snapshots/can_create_with_password@users.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/models/users.rs 3 | expression: res 4 | --- 5 | Ok( 6 | Model { 7 | created_at: DATE, 8 | updated_at: DATE, 9 | id: ID 10 | pid: PID, 11 | email: "test@framework.com", 12 | password: "PASSWORD", 13 | api_key: "lo-PID", 14 | name: "framework", 15 | reset_token: None, 16 | reset_sent_at: None, 17 | email_verification_token: None, 18 | email_verification_sent_at: None, 19 | email_verified_at: None, 20 | magic_link_token: None, 21 | magic_link_expiration: None, 22 | }, 23 | ) 24 | -------------------------------------------------------------------------------- /example/migration/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "migration" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [lib] 8 | name = "migration" 9 | path = "src/lib.rs" 10 | 11 | [dependencies] 12 | async-std = { version = "1", features = ["attributes", "tokio1"] } 13 | loco-rs = { workspace = true } 14 | 15 | 16 | [dependencies.sea-orm-migration] 17 | version = "1.1.0" 18 | features = [ 19 | # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. 20 | # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. 21 | # e.g. 22 | "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature 23 | ] 24 | -------------------------------------------------------------------------------- /example/tests/requests/snapshots/can_register@auth_request.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/requests/auth.rs 3 | expression: saved_user 4 | --- 5 | Ok( 6 | Model { 7 | created_at: DATE, 8 | updated_at: DATE, 9 | id: ID 10 | pid: PID, 11 | email: "test@loco.com", 12 | password: "PASSWORD", 13 | api_key: "lo-PID", 14 | name: "loco", 15 | reset_token: None, 16 | reset_sent_at: None, 17 | email_verification_token: Some( 18 | "PID", 19 | ), 20 | email_verification_sent_at: Some( 21 | DATE, 22 | ), 23 | email_verified_at: None, 24 | magic_link_token: None, 25 | magic_link_expiration: None, 26 | }, 27 | ) 28 | -------------------------------------------------------------------------------- /example/src/fixtures/users.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - id: 1 3 | pid: 11111111-1111-1111-1111-111111111111 4 | email: user1@example.com 5 | password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc" 6 | api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758 7 | name: user1 8 | created_at: "2023-11-12T12:34:56.789Z" 9 | updated_at: "2023-11-12T12:34:56.789Z" 10 | - id: 2 11 | pid: 22222222-2222-2222-2222-222222222222 12 | email: user2@example.com 13 | password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc" 14 | api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e 15 | name: user2 16 | created_at: "2023-11-12T12:34:56.789Z" 17 | updated_at: "2023-11-12T12:34:56.789Z" 18 | -------------------------------------------------------------------------------- /example/examples/playground.rs: -------------------------------------------------------------------------------- 1 | use loco_openapi_example::app::App; 2 | #[allow(unused_imports)] 3 | use loco_rs::{cli::playground, prelude::*}; 4 | 5 | #[tokio::main] 6 | async fn main() -> loco_rs::Result<()> { 7 | let _ctx = playground::().await?; 8 | 9 | // let active_model: articles::ActiveModel = ActiveModel { 10 | // title: Set(Some("how to build apps in 3 steps".to_string())), 11 | // content: Set(Some("use Loco: https://loco.rs".to_string())), 12 | // ..Default::default() 13 | // }; 14 | // active_model.insert(&ctx.db).await.unwrap(); 15 | 16 | // let res = articles::Entity::find().all(&ctx.db).await.unwrap(); 17 | // println!("{:?}", res); 18 | println!("welcome to playground. edit me at `examples/playground.rs`"); 19 | 20 | Ok(()) 21 | } 22 | -------------------------------------------------------------------------------- /example/tests/models/snapshots/can_find_by_pid@users.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/models/users.rs 3 | expression: existing_user 4 | --- 5 | Ok( 6 | Model { 7 | created_at: 2023-11-12T12:34:56.789+00:00, 8 | updated_at: 2023-11-12T12:34:56.789+00:00, 9 | id: 1, 10 | pid: 11111111-1111-1111-1111-111111111111, 11 | email: "user1@example.com", 12 | password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc", 13 | api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758", 14 | name: "user1", 15 | reset_token: None, 16 | reset_sent_at: None, 17 | email_verification_token: None, 18 | email_verification_sent_at: None, 19 | email_verified_at: None, 20 | magic_link_token: None, 21 | magic_link_expiration: None, 22 | }, 23 | ) 24 | -------------------------------------------------------------------------------- /example/tests/models/snapshots/can_find_by_email@users.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/models/users.rs 3 | expression: existing_user 4 | --- 5 | Ok( 6 | Model { 7 | created_at: 2023-11-12T12:34:56.789+00:00, 8 | updated_at: 2023-11-12T12:34:56.789+00:00, 9 | id: 1, 10 | pid: 11111111-1111-1111-1111-111111111111, 11 | email: "user1@example.com", 12 | password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc", 13 | api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758", 14 | name: "user1", 15 | reset_token: None, 16 | reset_sent_at: None, 17 | email_verification_token: None, 18 | email_verification_sent_at: None, 19 | email_verified_at: None, 20 | magic_link_token: None, 21 | magic_link_expiration: None, 22 | }, 23 | ) 24 | -------------------------------------------------------------------------------- /tests/ui/snapshots/r#mod__ui__reqeust__[__swagger__].snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui/reqeust.rs 3 | expression: res.text() 4 | --- 5 | 6 | 7 | 8 | 9 | 10 | Swagger UI 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /example/src/controllers/album.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::missing_errors_doc)] 2 | #![allow(clippy::unnecessary_struct_initialization)] 3 | #![allow(clippy::unused_async)] 4 | use axum::debug_handler; 5 | use loco_openapi::prelude::*; 6 | use loco_rs::prelude::*; 7 | use serde::Serialize; 8 | 9 | #[derive(Serialize, Debug, ToSchema)] 10 | pub struct Album { 11 | title: String, 12 | rating: u32, 13 | } 14 | 15 | /// Get album 16 | /// 17 | /// Returns a title and rating 18 | #[utoipa::path( 19 | get, 20 | path = "/api/album/get_album", 21 | tags = ["album"], 22 | responses( 23 | (status = 200, description = "Album found", body = Album), 24 | ), 25 | )] 26 | #[debug_handler] 27 | pub async fn get_album(State(_ctx): State) -> Result { 28 | format::json(Album { 29 | title: "VH II".to_string(), 30 | rating: 10, 31 | }) 32 | } 33 | 34 | pub fn routes() -> Routes { 35 | Routes::new() 36 | .prefix("api/album/") 37 | .add("/get_album", openapi(get(get_album), routes!(get_album))) 38 | } 39 | -------------------------------------------------------------------------------- /example/src/views/auth.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::models::_entities::users; 4 | 5 | #[derive(Debug, Deserialize, Serialize)] 6 | pub struct LoginResponse { 7 | pub token: String, 8 | pub pid: String, 9 | pub name: String, 10 | pub is_verified: bool, 11 | } 12 | 13 | impl LoginResponse { 14 | #[must_use] 15 | pub fn new(user: &users::Model, token: &String) -> Self { 16 | Self { 17 | token: token.to_string(), 18 | pid: user.pid.to_string(), 19 | name: user.name.clone(), 20 | is_verified: user.email_verified_at.is_some(), 21 | } 22 | } 23 | } 24 | 25 | #[derive(Debug, Deserialize, Serialize)] 26 | pub struct CurrentResponse { 27 | pub pid: String, 28 | pub name: String, 29 | pub email: String, 30 | } 31 | 32 | impl CurrentResponse { 33 | #[must_use] 34 | pub fn new(user: &users::Model) -> Self { 35 | Self { 36 | pid: user.pid.to_string(), 37 | name: user.name.clone(), 38 | email: user.email.clone(), 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /example/src/models/_entities/users.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] 7 | #[sea_orm(table_name = "users")] 8 | pub struct Model { 9 | pub created_at: DateTimeWithTimeZone, 10 | pub updated_at: DateTimeWithTimeZone, 11 | #[sea_orm(primary_key)] 12 | pub id: i32, 13 | pub pid: Uuid, 14 | #[sea_orm(unique)] 15 | pub email: String, 16 | pub password: String, 17 | #[sea_orm(unique)] 18 | pub api_key: String, 19 | pub name: String, 20 | pub reset_token: Option, 21 | pub reset_sent_at: Option, 22 | pub email_verification_token: Option, 23 | pub email_verification_sent_at: Option, 24 | pub email_verified_at: Option, 25 | pub magic_link_token: Option, 26 | pub magic_link_expiration: Option, 27 | } 28 | 29 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 30 | pub enum Relation {} 31 | -------------------------------------------------------------------------------- /tests/ui/snapshots/r#mod__ui__reqeust__[__redoc__openapi.yaml].snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui/reqeust.rs 3 | expression: yaml_value 4 | --- 5 | openapi: 3.1.0 6 | info: 7 | title: Loco Demo Test 8 | description: Test OpenAPI spec for loco-openapi 9 | license: 10 | name: MIT OR Apache-2.0 11 | identifier: MIT OR Apache-2.0 12 | version: "[version]" 13 | paths: 14 | /api/album/get_album: 15 | get: 16 | tags: 17 | - album 18 | summary: Get album 19 | description: Returns a title and rating 20 | operationId: get_album 21 | responses: 22 | "200": 23 | description: Album found 24 | content: 25 | application/json: 26 | schema: 27 | $ref: "#/components/schemas/Album" 28 | components: 29 | schemas: 30 | Album: 31 | type: object 32 | required: 33 | - title 34 | - rating 35 | properties: 36 | rating: 37 | type: integer 38 | format: int32 39 | minimum: 0 40 | title: 41 | type: string 42 | securitySchemes: 43 | api_key: 44 | type: apiKey 45 | in: header 46 | name: apikey 47 | jwt_token: 48 | type: http 49 | scheme: bearer 50 | bearerFormat: JWT 51 | -------------------------------------------------------------------------------- /tests/ui/snapshots/r#mod__ui__reqeust__[__scalar__openapi.yaml].snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui/reqeust.rs 3 | expression: yaml_value 4 | --- 5 | openapi: 3.1.0 6 | info: 7 | title: Loco Demo Test 8 | description: Test OpenAPI spec for loco-openapi 9 | license: 10 | name: MIT OR Apache-2.0 11 | identifier: MIT OR Apache-2.0 12 | version: "[version]" 13 | paths: 14 | /api/album/get_album: 15 | get: 16 | tags: 17 | - album 18 | summary: Get album 19 | description: Returns a title and rating 20 | operationId: get_album 21 | responses: 22 | "200": 23 | description: Album found 24 | content: 25 | application/json: 26 | schema: 27 | $ref: "#/components/schemas/Album" 28 | components: 29 | schemas: 30 | Album: 31 | type: object 32 | required: 33 | - title 34 | - rating 35 | properties: 36 | rating: 37 | type: integer 38 | format: int32 39 | minimum: 0 40 | title: 41 | type: string 42 | securitySchemes: 43 | api_key: 44 | type: apiKey 45 | in: header 46 | name: apikey 47 | jwt_token: 48 | type: http 49 | scheme: bearer 50 | bearerFormat: JWT 51 | -------------------------------------------------------------------------------- /tests/ui/snapshots/r#mod__ui__reqeust__[__swagger__openapi.yaml].snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui/reqeust.rs 3 | expression: yaml_value 4 | --- 5 | openapi: 3.1.0 6 | info: 7 | title: Loco Demo Test 8 | description: Test OpenAPI spec for loco-openapi 9 | license: 10 | name: MIT OR Apache-2.0 11 | identifier: MIT OR Apache-2.0 12 | version: "[version]" 13 | paths: 14 | /api/album/get_album: 15 | get: 16 | tags: 17 | - album 18 | summary: Get album 19 | description: Returns a title and rating 20 | operationId: get_album 21 | responses: 22 | "200": 23 | description: Album found 24 | content: 25 | application/json: 26 | schema: 27 | $ref: "#/components/schemas/Album" 28 | components: 29 | schemas: 30 | Album: 31 | type: object 32 | required: 33 | - title 34 | - rating 35 | properties: 36 | rating: 37 | type: integer 38 | format: int32 39 | minimum: 0 40 | title: 41 | type: string 42 | securitySchemes: 43 | api_key: 44 | type: apiKey 45 | in: header 46 | name: apikey 47 | jwt_token: 48 | type: http 49 | scheme: bearer 50 | bearerFormat: JWT 51 | -------------------------------------------------------------------------------- /tests/ui/snapshots/r#mod__ui__reqeust__[__scalar].snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui/reqeust.rs 3 | expression: res.text() 4 | --- 5 | 6 | 7 | 8 | Scalar 9 | 10 | 13 | 14 | 15 | 16 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "loco-openapi" 3 | version = "0.1.2" 4 | edition = "2021" 5 | publish = true 6 | license = "MIT OR Apache-2.0" 7 | repository = "https://github.com/loco-rs/loco-openapi-Initializer" 8 | homepage = "https://loco.rs/" 9 | description = "OpenAPI support to Loco framework" 10 | 11 | [lib] 12 | path = "src/lib.rs" 13 | 14 | [features] 15 | default = ["full"] 16 | full = ["swagger", "redoc", "scalar"] 17 | swagger = ["dep:utoipa-swagger-ui"] 18 | redoc = ["dep:utoipa-redoc"] 19 | scalar = ["dep:utoipa-scalar"] 20 | 21 | 22 | [workspace.dependencies] 23 | loco-rs = { version = "0.16", default-features = false } 24 | 25 | [dependencies] 26 | loco-rs = { workspace = true } 27 | serde = { version = "1", features = ["derive"] } 28 | serde_json = { version = "1" } 29 | async-trait = { version = "0.1" } 30 | axum = { version = "0.8.1" } 31 | 32 | # OpenAPI 33 | utoipa = { version = "5.0.0", features = ["yaml"] } 34 | utoipa-axum = { version = "0.2.0" } 35 | utoipa-swagger-ui = { version = "9.0.0", features = [ 36 | "axum", 37 | "vendored", 38 | ], optional = true } 39 | utoipa-redoc = { version = "6.0.0", features = ["axum"], optional = true } 40 | utoipa-scalar = { version = "0.3.0", features = ["axum"], optional = true } 41 | 42 | [dev-dependencies] 43 | loco-rs = { workspace = true, features = ["testing"] } 44 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 45 | insta = { version = "1.34.0", features = [ 46 | "redactions", 47 | "yaml", 48 | "json", 49 | "filters", 50 | ] } 51 | rstest = { version = "0.21.0" } 52 | serde_yaml = { version = "0.9" } 53 | serial_test = "3.2.0" 54 | -------------------------------------------------------------------------------- /example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | [package] 4 | name = "loco-openapi-example" 5 | version = "0.1.0" 6 | edition = "2024" 7 | publish = false 8 | default-run = "loco_openapi-cli" 9 | license = "MIT OR Apache-2.0" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [workspace.dependencies] 14 | loco-rs = { version = "0.16" } 15 | 16 | [dependencies] 17 | loco-openapi = { path = "../", features = ["full"] } 18 | loco-rs = { workspace = true } 19 | serde = { version = "1", features = ["derive"] } 20 | serde_json = { version = "1" } 21 | tokio = { version = "1.33.0", default-features = false, features = [ 22 | "rt-multi-thread", 23 | ] } 24 | async-trait = { version = "0.1.74" } 25 | axum = { version = "0.8.1" } 26 | tracing = { version = "0.1.40" } 27 | tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] } 28 | regex = { version = "1.11.1" } 29 | migration = { path = "migration" } 30 | sea-orm = { version = "1.1.0", features = [ 31 | "sqlx-sqlite", 32 | "sqlx-postgres", 33 | "runtime-tokio-rustls", 34 | "macros", 35 | ] } 36 | chrono = { version = "0.4" } 37 | validator = { version = "0.20" } 38 | uuid = { version = "1.6.0", features = ["v4"] } 39 | include_dir = { version = "0.7" } 40 | 41 | [[bin]] 42 | name = "loco_openapi-cli" 43 | path = "src/bin/main.rs" 44 | required-features = [] 45 | 46 | [dev-dependencies] 47 | loco-rs = { workspace = true, features = ["testing"] } 48 | serial_test = { version = "3.1.1" } 49 | rstest = { version = "0.21.0" } 50 | insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] } 51 | -------------------------------------------------------------------------------- /tests/ui/snapshots/r#mod__ui__reqeust__[__redoc].snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui/reqeust.rs 3 | expression: res.text() 4 | --- 5 | 6 | 7 | 8 | Redoc 9 | 10 | 11 | 15 | 16 | 22 | 23 | 24 | 25 |
26 | 27 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::sync::OnceLock; 2 | 3 | use axum::{response::Response, routing::get, Router as AxumRouter}; 4 | use utoipa::openapi::OpenApi; 5 | 6 | use loco_rs::{controller::format, Result}; 7 | 8 | static OPENAPI_SPEC: OnceLock = OnceLock::new(); 9 | 10 | pub fn set_openapi_spec(api: OpenApi) -> &'static OpenApi { 11 | OPENAPI_SPEC.get_or_init(|| api) 12 | } 13 | 14 | /// # Panics 15 | /// 16 | /// Will panic if `OpenAPI` spec fails to build 17 | pub fn get_openapi_spec() -> &'static OpenApi { 18 | OPENAPI_SPEC.get().unwrap() 19 | } 20 | 21 | /// Axum handler that returns the `OpenAPI` spec as JSON 22 | /// 23 | /// # Errors 24 | /// Currently this function doesn't return any error. this is for feature 25 | /// functionality 26 | pub async fn openapi_spec_json() -> Result { 27 | format::json(get_openapi_spec()) 28 | } 29 | 30 | /// Axum handler that returns the `OpenAPI` spec as YAML 31 | /// 32 | /// # Errors 33 | /// Currently this function doesn't return any error. this is for feature 34 | /// functionality 35 | pub async fn openapi_spec_yaml() -> Result { 36 | let yaml = get_openapi_spec() 37 | .to_yaml() 38 | .map_err(|e| loco_rs::Error::Any(Box::new(e)))?; 39 | format::yaml(&yaml) 40 | } 41 | 42 | /// Adds the `OpenAPI` endpoints the app router 43 | pub fn add_openapi_endpoints( 44 | mut app: AxumRouter, 45 | json_url: &Option, 46 | yaml_url: &Option, 47 | ) -> AxumRouter 48 | where 49 | T: Clone + Send + Sync + 'static, 50 | { 51 | if let Some(json_url) = json_url { 52 | app = app.route(json_url, get(openapi_spec_json)); 53 | } 54 | if let Some(yaml_url) = yaml_url { 55 | app = app.route(yaml_url, get(openapi_spec_yaml)); 56 | } 57 | app 58 | } 59 | -------------------------------------------------------------------------------- /example/migration/src/m20220101_000001_users.rs: -------------------------------------------------------------------------------- 1 | use loco_rs::schema::table_auto_tz; 2 | use sea_orm_migration::{prelude::*, schema::*}; 3 | 4 | #[derive(DeriveMigrationName)] 5 | pub struct Migration; 6 | 7 | #[async_trait::async_trait] 8 | impl MigrationTrait for Migration { 9 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 10 | let table = table_auto_tz(Users::Table) 11 | .col(pk_auto(Users::Id)) 12 | .col(uuid(Users::Pid)) 13 | .col(string_uniq(Users::Email)) 14 | .col(string(Users::Password)) 15 | .col(string(Users::ApiKey).unique_key()) 16 | .col(string(Users::Name)) 17 | .col(string_null(Users::ResetToken)) 18 | .col(timestamp_with_time_zone_null(Users::ResetSentAt)) 19 | .col(string_null(Users::EmailVerificationToken)) 20 | .col(timestamp_with_time_zone_null( 21 | Users::EmailVerificationSentAt, 22 | )) 23 | .col(timestamp_with_time_zone_null(Users::EmailVerifiedAt)) 24 | .col(string_null(Users::MagicLinkToken)) 25 | .col(timestamp_with_time_zone_null(Users::MagicLinkExpiration)) 26 | .to_owned(); 27 | manager.create_table(table).await?; 28 | Ok(()) 29 | } 30 | 31 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 32 | manager 33 | .drop_table(Table::drop().table(Users::Table).to_owned()) 34 | .await 35 | } 36 | } 37 | 38 | #[derive(Iden)] 39 | pub enum Users { 40 | Table, 41 | Id, 42 | Pid, 43 | Email, 44 | Name, 45 | Password, 46 | ApiKey, 47 | ResetToken, 48 | ResetSentAt, 49 | EmailVerificationToken, 50 | EmailVerificationSentAt, 51 | EmailVerifiedAt, 52 | MagicLinkToken, 53 | MagicLinkExpiration, 54 | } 55 | -------------------------------------------------------------------------------- /tests/ui/snapshots/r#mod__ui__reqeust__[__redoc__openapi.json].snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui/reqeust.rs 3 | expression: json_value 4 | --- 5 | { 6 | "components": { 7 | "schemas": { 8 | "Album": { 9 | "properties": { 10 | "rating": { 11 | "format": "int32", 12 | "minimum": 0, 13 | "type": "integer" 14 | }, 15 | "title": { 16 | "type": "string" 17 | } 18 | }, 19 | "required": [ 20 | "title", 21 | "rating" 22 | ], 23 | "type": "object" 24 | } 25 | }, 26 | "securitySchemes": { 27 | "api_key": { 28 | "in": "header", 29 | "name": "apikey", 30 | "type": "apiKey" 31 | }, 32 | "jwt_token": { 33 | "bearerFormat": "JWT", 34 | "scheme": "bearer", 35 | "type": "http" 36 | } 37 | } 38 | }, 39 | "info": { 40 | "description": "Test OpenAPI spec for loco-openapi", 41 | "license": { 42 | "identifier": "MIT OR Apache-2.0", 43 | "name": "MIT OR Apache-2.0" 44 | }, 45 | "title": "Loco Demo Test", 46 | "version": "[version]" 47 | }, 48 | "openapi": "3.1.0", 49 | "paths": { 50 | "/api/album/get_album": { 51 | "get": { 52 | "description": "Returns a title and rating", 53 | "operationId": "get_album", 54 | "responses": { 55 | "200": { 56 | "content": { 57 | "application/json": { 58 | "schema": { 59 | "$ref": "#/components/schemas/Album" 60 | } 61 | } 62 | }, 63 | "description": "Album found" 64 | } 65 | }, 66 | "summary": "Get album", 67 | "tags": [ 68 | "album" 69 | ] 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/ui/snapshots/r#mod__ui__reqeust__[__scalar__openapi.json].snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui/reqeust.rs 3 | expression: json_value 4 | --- 5 | { 6 | "components": { 7 | "schemas": { 8 | "Album": { 9 | "properties": { 10 | "rating": { 11 | "format": "int32", 12 | "minimum": 0, 13 | "type": "integer" 14 | }, 15 | "title": { 16 | "type": "string" 17 | } 18 | }, 19 | "required": [ 20 | "title", 21 | "rating" 22 | ], 23 | "type": "object" 24 | } 25 | }, 26 | "securitySchemes": { 27 | "api_key": { 28 | "in": "header", 29 | "name": "apikey", 30 | "type": "apiKey" 31 | }, 32 | "jwt_token": { 33 | "bearerFormat": "JWT", 34 | "scheme": "bearer", 35 | "type": "http" 36 | } 37 | } 38 | }, 39 | "info": { 40 | "description": "Test OpenAPI spec for loco-openapi", 41 | "license": { 42 | "identifier": "MIT OR Apache-2.0", 43 | "name": "MIT OR Apache-2.0" 44 | }, 45 | "title": "Loco Demo Test", 46 | "version": "[version]" 47 | }, 48 | "openapi": "3.1.0", 49 | "paths": { 50 | "/api/album/get_album": { 51 | "get": { 52 | "description": "Returns a title and rating", 53 | "operationId": "get_album", 54 | "responses": { 55 | "200": { 56 | "content": { 57 | "application/json": { 58 | "schema": { 59 | "$ref": "#/components/schemas/Album" 60 | } 61 | } 62 | }, 63 | "description": "Album found" 64 | } 65 | }, 66 | "summary": "Get album", 67 | "tags": [ 68 | "album" 69 | ] 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/ui/snapshots/r#mod__ui__reqeust__[__swagger__openapi.json].snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui/reqeust.rs 3 | expression: json_value 4 | --- 5 | { 6 | "components": { 7 | "schemas": { 8 | "Album": { 9 | "properties": { 10 | "rating": { 11 | "format": "int32", 12 | "minimum": 0, 13 | "type": "integer" 14 | }, 15 | "title": { 16 | "type": "string" 17 | } 18 | }, 19 | "required": [ 20 | "title", 21 | "rating" 22 | ], 23 | "type": "object" 24 | } 25 | }, 26 | "securitySchemes": { 27 | "api_key": { 28 | "in": "header", 29 | "name": "apikey", 30 | "type": "apiKey" 31 | }, 32 | "jwt_token": { 33 | "bearerFormat": "JWT", 34 | "scheme": "bearer", 35 | "type": "http" 36 | } 37 | } 38 | }, 39 | "info": { 40 | "description": "Test OpenAPI spec for loco-openapi", 41 | "license": { 42 | "identifier": "MIT OR Apache-2.0", 43 | "name": "MIT OR Apache-2.0" 44 | }, 45 | "title": "Loco Demo Test", 46 | "version": "[version]" 47 | }, 48 | "openapi": "3.1.0", 49 | "paths": { 50 | "/api/album/get_album": { 51 | "get": { 52 | "description": "Returns a title and rating", 53 | "operationId": "get_album", 54 | "responses": { 55 | "200": { 56 | "content": { 57 | "application/json": { 58 | "schema": { 59 | "$ref": "#/components/schemas/Album" 60 | } 61 | } 62 | }, 63 | "description": "Album found" 64 | } 65 | }, 66 | "summary": "Get album", 67 | "tags": [ 68 | "album" 69 | ] 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /example/tests/requests/prepare_data.rs: -------------------------------------------------------------------------------- 1 | use axum::http::{HeaderName, HeaderValue}; 2 | use loco_openapi_example::{models::users, views::auth::LoginResponse}; 3 | use loco_rs::{TestServer, app::AppContext}; 4 | 5 | const USER_EMAIL: &str = "test@loco.com"; 6 | const USER_PASSWORD: &str = "1234"; 7 | 8 | pub struct LoggedInUser { 9 | pub user: users::Model, 10 | pub token: String, 11 | } 12 | 13 | pub async fn init_user_login(request: &TestServer, ctx: &AppContext) -> LoggedInUser { 14 | let register_payload = serde_json::json!({ 15 | "name": "loco", 16 | "email": USER_EMAIL, 17 | "password": USER_PASSWORD 18 | }); 19 | 20 | //Creating a new user 21 | request 22 | .post("/api/auth/register") 23 | .json(®ister_payload) 24 | .await; 25 | let user = users::Model::find_by_email(&ctx.db, USER_EMAIL) 26 | .await 27 | .unwrap(); 28 | 29 | let verify_payload = serde_json::json!({ 30 | "token": user.email_verification_token, 31 | }); 32 | 33 | request.post("/api/auth/verify").json(&verify_payload).await; 34 | 35 | let response = request 36 | .post("/api/auth/login") 37 | .json(&serde_json::json!({ 38 | "email": USER_EMAIL, 39 | "password": USER_PASSWORD 40 | })) 41 | .await; 42 | 43 | let login_response: LoginResponse = serde_json::from_str(&response.text()).unwrap(); 44 | 45 | LoggedInUser { 46 | user: users::Model::find_by_email(&ctx.db, USER_EMAIL) 47 | .await 48 | .unwrap(), 49 | token: login_response.token, 50 | } 51 | } 52 | 53 | pub fn auth_header(token: &str) -> (HeaderName, HeaderValue) { 54 | let auth_header_value = HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap(); 55 | 56 | (HeaderName::from_static("authorization"), auth_header_value) 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/example.yaml: -------------------------------------------------------------------------------- 1 | name: Example 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | env: 9 | RUST_TOOLCHAIN: stable 10 | TOOLCHAIN_PROFILE: minimal 11 | 12 | jobs: 13 | rustfmt: 14 | name: Check Style 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | contents: read 19 | 20 | steps: 21 | - name: Checkout the code 22 | uses: actions/checkout@v4 23 | - uses: dtolnay/rust-toolchain@stable 24 | with: 25 | toolchain: ${{ env.RUST_TOOLCHAIN }} 26 | components: rustfmt 27 | - name: Run cargo fmt 28 | run: cargo fmt --all -- --check 29 | working-directory: ./example 30 | 31 | clippy: 32 | name: Run Clippy 33 | runs-on: ubuntu-latest 34 | 35 | permissions: 36 | contents: read 37 | 38 | steps: 39 | - name: Checkout the code 40 | uses: actions/checkout@v4 41 | - uses: dtolnay/rust-toolchain@stable 42 | with: 43 | toolchain: ${{ env.RUST_TOOLCHAIN }} 44 | - name: Setup Rust cache 45 | uses: Swatinem/rust-cache@v2 46 | - name: Run cargo clippy 47 | run: cargo clippy --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms -A clippy::result_large_err 48 | working-directory: ./example 49 | 50 | 51 | test: 52 | name: Run Tests 53 | runs-on: ubuntu-latest 54 | 55 | permissions: 56 | contents: read 57 | 58 | steps: 59 | - name: Checkout the code 60 | uses: actions/checkout@v4 61 | - uses: dtolnay/rust-toolchain@stable 62 | with: 63 | toolchain: ${{ env.RUST_TOOLCHAIN }} 64 | - name: Setup Rust cache 65 | uses: Swatinem/rust-cache@v2 66 | - name: Run cargo test 67 | run: cargo test --all-features --all 68 | working-directory: ./example -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | env: 9 | RUST_TOOLCHAIN: stable 10 | TOOLCHAIN_PROFILE: minimal 11 | 12 | jobs: 13 | check: 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | 19 | steps: 20 | - name: Checkout the code 21 | uses: actions/checkout@v4 22 | - uses: dtolnay/rust-toolchain@stable 23 | with: 24 | toolchain: ${{ env.RUST_TOOLCHAIN }} 25 | - name: Setup Rust cache 26 | uses: Swatinem/rust-cache@v2 27 | - uses: taiki-e/install-action@v2 28 | with: 29 | tool: cargo-hack 30 | - run: cargo hack check --each-feature 31 | 32 | rustfmt: 33 | name: Check Style 34 | runs-on: ubuntu-latest 35 | 36 | permissions: 37 | contents: read 38 | 39 | steps: 40 | - name: Checkout the code 41 | uses: actions/checkout@v4 42 | - uses: dtolnay/rust-toolchain@stable 43 | with: 44 | toolchain: ${{ env.RUST_TOOLCHAIN }} 45 | components: rustfmt 46 | - name: Setup Rust cache 47 | uses: Swatinem/rust-cache@v2 48 | - uses: taiki-e/install-action@v2 49 | with: 50 | tool: cargo-hack 51 | - name: Run cargo fmt 52 | run: cargo fmt --all -- --check 53 | 54 | clippy: 55 | name: Run Clippy 56 | runs-on: ubuntu-latest 57 | 58 | permissions: 59 | contents: read 60 | 61 | steps: 62 | - name: Checkout the code 63 | uses: actions/checkout@v4 64 | - uses: dtolnay/rust-toolchain@stable 65 | with: 66 | toolchain: ${{ env.RUST_TOOLCHAIN }} 67 | - name: Setup Rust cache 68 | uses: Swatinem/rust-cache@v2 69 | - name: Run cargo clippy 70 | run: cargo clippy --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms -A clippy::result_large_err 71 | 72 | test: 73 | name: Run Tests 74 | runs-on: ubuntu-latest 75 | 76 | permissions: 77 | contents: read 78 | 79 | steps: 80 | - name: Checkout the code 81 | uses: actions/checkout@v4 82 | - uses: dtolnay/rust-toolchain@stable 83 | with: 84 | toolchain: ${{ env.RUST_TOOLCHAIN }} 85 | - name: Setup Rust cache 86 | uses: Swatinem/rust-cache@v2 87 | - uses: taiki-e/install-action@v2 88 | with: 89 | tool: cargo-hack 90 | - run: cargo hack test --each-feature 91 | -------------------------------------------------------------------------------- /src/openapi.rs: -------------------------------------------------------------------------------- 1 | use loco_rs::app::AppContext; 2 | use std::sync::{Mutex, OnceLock}; 3 | use utoipa_axum::router::{OpenApiRouter, UtoipaMethodRouter}; 4 | 5 | static OPENAPI_ROUTES: OnceLock>>> = OnceLock::new(); 6 | 7 | fn get_routes() -> &'static Mutex>> { 8 | OPENAPI_ROUTES.get_or_init(|| Mutex::new(Vec::new())) 9 | } 10 | 11 | // Register a route for later merging 12 | pub fn add_route(route: OpenApiRouter) { 13 | if let Ok(mut routes) = get_routes().lock() { 14 | routes.push(route); 15 | } 16 | } 17 | 18 | // Clears all registered routes in the `OPENAPI_ROUTES` 19 | // Mostly used for testing, to prevent routes added from different test runs from overlapping 20 | pub fn clear_routes() { 21 | if let Ok(mut routes) = get_routes().lock() { 22 | routes.clear(); 23 | } 24 | } 25 | 26 | // Get a merged router containing all collected routes 27 | #[must_use] 28 | pub fn get_merged_router() -> OpenApiRouter { 29 | let mut result = OpenApiRouter::new(); 30 | 31 | if let Ok(routes) = get_routes().lock() { 32 | for route in routes.iter() { 33 | result = result.merge(route.clone()); 34 | } 35 | } 36 | result 37 | } 38 | 39 | /// Auto collect the openapi routes 40 | /// ```rust 41 | /// # use axum::debug_handler; 42 | /// use loco_openapi::prelude::*; 43 | /// # use loco_rs::prelude::*; 44 | /// # use serde::Serialize; 45 | /// # #[derive(Serialize, Debug, ToSchema)] 46 | /// # pub struct Album { 47 | /// # title: String, 48 | /// # rating: u32, 49 | /// # } 50 | /// # #[utoipa::path( 51 | /// # get, 52 | /// # path = "/api/album/get_album", 53 | /// # tags = ["album"], 54 | /// # responses( 55 | /// # (status = 200, description = "Album found", body = Album), 56 | /// # ), 57 | /// # )] 58 | /// # #[debug_handler] 59 | /// # pub async fn get_album(State(_ctx): State) -> Result { 60 | /// # format::json(Album { 61 | /// # title: "VH II".to_string(), 62 | /// # rating: 10, 63 | /// # }) 64 | /// # } 65 | /// 66 | /// // Swap from: 67 | /// Routes::new() 68 | /// .add("/album", get(get_album)); 69 | /// // To: 70 | /// Routes::new() 71 | /// .add("/get_album", openapi(get(get_album), routes!(get_album))); 72 | /// ``` 73 | pub fn openapi( 74 | method: axum::routing::MethodRouter, 75 | method_openapi: UtoipaMethodRouter, 76 | ) -> axum::routing::MethodRouter { 77 | let router = OpenApiRouter::new().routes(method_openapi); 78 | add_route(router); 79 | method 80 | } 81 | -------------------------------------------------------------------------------- /example/src/mailers/auth.rs: -------------------------------------------------------------------------------- 1 | // auth mailer 2 | #![allow(non_upper_case_globals)] 3 | 4 | use loco_rs::prelude::*; 5 | use serde_json::json; 6 | 7 | use crate::models::users; 8 | 9 | static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome"); 10 | static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot"); 11 | static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link"); 12 | // #[derive(Mailer)] // -- disabled for faster build speed. it works. but lets 13 | // move on for now. 14 | 15 | #[allow(clippy::module_name_repetitions)] 16 | pub struct AuthMailer {} 17 | impl Mailer for AuthMailer {} 18 | impl AuthMailer { 19 | /// Sending welcome email the the given user 20 | /// 21 | /// # Errors 22 | /// 23 | /// When email sending is failed 24 | pub async fn send_welcome(ctx: &AppContext, user: &users::Model) -> Result<()> { 25 | Self::mail_template( 26 | ctx, 27 | &welcome, 28 | mailer::Args { 29 | to: user.email.to_string(), 30 | locals: json!({ 31 | "name": user.name, 32 | "verifyToken": user.email_verification_token, 33 | "domain": ctx.config.server.full_url() 34 | }), 35 | ..Default::default() 36 | }, 37 | ) 38 | .await?; 39 | 40 | Ok(()) 41 | } 42 | 43 | /// Sending forgot password email 44 | /// 45 | /// # Errors 46 | /// 47 | /// When email sending is failed 48 | pub async fn forgot_password(ctx: &AppContext, user: &users::Model) -> Result<()> { 49 | Self::mail_template( 50 | ctx, 51 | &forgot, 52 | mailer::Args { 53 | to: user.email.to_string(), 54 | locals: json!({ 55 | "name": user.name, 56 | "resetToken": user.reset_token, 57 | "domain": ctx.config.server.full_url() 58 | }), 59 | ..Default::default() 60 | }, 61 | ) 62 | .await?; 63 | 64 | Ok(()) 65 | } 66 | 67 | /// Sends a magic link authentication email to the user. 68 | /// 69 | /// # Errors 70 | /// 71 | /// When email sending is failed 72 | pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> { 73 | Self::mail_template( 74 | ctx, 75 | &magic_link, 76 | mailer::Args { 77 | to: user.email.to_string(), 78 | locals: json!({ 79 | "name": user.name, 80 | "token": user.magic_link_token.clone().ok_or_else(|| Error::string( 81 | "the user model not contains magic link token", 82 | ))?, 83 | "host": ctx.config.server.full_url() 84 | }), 85 | ..Default::default() 86 | }, 87 | ) 88 | .await?; 89 | 90 | Ok(()) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /example/src/app.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use loco_openapi::prelude::*; 3 | use loco_rs::{ 4 | Result, 5 | app::{AppContext, Hooks, Initializer}, 6 | bgworker::{BackgroundWorker, Queue}, 7 | boot::{BootResult, StartMode, create_app}, 8 | config::Config, 9 | controller::AppRoutes, 10 | db::{self, truncate_table}, 11 | environment::Environment, 12 | task::Tasks, 13 | }; 14 | use migration::Migrator; 15 | use std::path::Path; 16 | 17 | #[allow(unused_imports)] 18 | use crate::{controllers, models::_entities::users, tasks, workers::downloader::DownloadWorker}; 19 | 20 | pub struct App; 21 | #[async_trait] 22 | impl Hooks for App { 23 | fn app_name() -> &'static str { 24 | env!("CARGO_CRATE_NAME") 25 | } 26 | 27 | fn app_version() -> String { 28 | format!( 29 | "{} ({})", 30 | env!("CARGO_PKG_VERSION"), 31 | option_env!("BUILD_SHA") 32 | .or(option_env!("GITHUB_SHA")) 33 | .unwrap_or("dev") 34 | ) 35 | } 36 | 37 | async fn boot( 38 | mode: StartMode, 39 | environment: &Environment, 40 | config: Config, 41 | ) -> Result { 42 | create_app::(mode, environment, config).await 43 | } 44 | 45 | async fn initializers(ctx: &AppContext) -> Result>> { 46 | let mut initializers: Vec> = vec![]; 47 | 48 | if ctx.environment != Environment::Test { 49 | initializers.push( 50 | Box::new( 51 | loco_openapi::OpenapiInitializerWithSetup::new( 52 | |ctx| { 53 | #[derive(OpenApi)] 54 | #[openapi( 55 | modifiers(&SecurityAddon), 56 | info( 57 | title = "Loco Demo", 58 | description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." 59 | ) 60 | )] 61 | struct ApiDoc; 62 | set_jwt_location(ctx.into()); 63 | 64 | ApiDoc::openapi() 65 | }, 66 | None, 67 | ), 68 | ) as Box 69 | ); 70 | } 71 | 72 | Ok(initializers) 73 | } 74 | 75 | fn routes(_ctx: &AppContext) -> AppRoutes { 76 | AppRoutes::with_default_routes() // controller routes below 77 | .add_route(controllers::album::routes()) 78 | .add_route(controllers::auth::routes()) 79 | } 80 | async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { 81 | queue.register(DownloadWorker::build(ctx)).await?; 82 | Ok(()) 83 | } 84 | 85 | #[allow(unused_variables)] 86 | fn register_tasks(tasks: &mut Tasks) { 87 | // tasks-inject (do not remove) 88 | } 89 | async fn truncate(ctx: &AppContext) -> Result<()> { 90 | truncate_table(&ctx.db, users::Entity).await?; 91 | Ok(()) 92 | } 93 | async fn seed(ctx: &AppContext, base: &Path) -> Result<()> { 94 | db::seed::(&ctx.db, &base.join("users.yaml").display().to_string()) 95 | .await?; 96 | Ok(()) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /example/config/test.yaml: -------------------------------------------------------------------------------- 1 | # Loco configuration file documentation 2 | 3 | # Application logging configuration 4 | logger: 5 | # Enable or disable logging. 6 | enable: false 7 | # Enable pretty backtrace (sets RUST_BACKTRACE=1) 8 | pretty_backtrace: true 9 | # Log level, options: trace, debug, info, warn or error. 10 | level: debug 11 | # Define the logging format. options: compact, pretty or json 12 | format: compact 13 | # By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries 14 | # Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters. 15 | # override_filter: trace 16 | 17 | # Web server configuration 18 | server: 19 | # Port on which the server will listen. the server binding is 0.0.0.0:{PORT} 20 | port: 5150 21 | # The UI hostname or IP address that mailers will point to. 22 | host: http://localhost 23 | # Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block 24 | 25 | # Worker Configuration 26 | workers: 27 | # specifies the worker mode. Options: 28 | # - BackgroundQueue - Workers operate asynchronously in the background, processing queued. 29 | # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. 30 | # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. 31 | mode: ForegroundBlocking 32 | 33 | 34 | 35 | # Mailer Configuration. 36 | mailer: 37 | stub: true 38 | # SMTP mailer configuration. 39 | smtp: 40 | # Enable/Disable smtp mailer. 41 | enable: true 42 | # SMTP server host. e.x localhost, smtp.gmail.com 43 | host: localhost 44 | # SMTP server port 45 | port: 1025 46 | # Use secure connection (SSL/TLS). 47 | secure: false 48 | # auth: 49 | # user: 50 | # password: 51 | 52 | # Initializers Configuration 53 | # initializers: 54 | # oauth2: 55 | # authorization_code: # Authorization code grant type 56 | # - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config. 57 | # ... other fields 58 | initializers: 59 | openapi: 60 | scalar: 61 | url: /scalar 62 | redoc: 63 | url: /redoc 64 | swagger: 65 | url: /swagger 66 | spec_json_url: /api-docs/openapi.json 67 | 68 | # Database Configuration 69 | database: 70 | # Database connection URI 71 | uri: {{ get_env(name="DATABASE_URL", default="sqlite://loco-openapi_test.sqlite?mode=rwc") }} 72 | # When enabled, the sql query will be logged. 73 | enable_logging: false 74 | # Set the timeout duration when acquiring a connection. 75 | connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }} 76 | # Set the idle duration before closing a connection. 77 | idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }} 78 | # Minimum number of connections for a pool. 79 | min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }} 80 | # Maximum number of connections for a pool. 81 | max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }} 82 | # Run migration up when application loaded 83 | auto_migrate: true 84 | # Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode 85 | dangerously_truncate: true 86 | # Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode 87 | dangerously_recreate: true 88 | 89 | # Authentication Configuration 90 | auth: 91 | # JWT authentication 92 | jwt: 93 | # Secret key for token generation and verification 94 | secret: sQAX9LCnjrp8YbKkVlgb 95 | # Token expiration time in seconds 96 | expiration: 604800 # 7 days 97 | -------------------------------------------------------------------------------- /example/config/development.yaml: -------------------------------------------------------------------------------- 1 | # Loco configuration file documentation 2 | 3 | # Application logging configuration 4 | logger: 5 | # Enable or disable logging. 6 | enable: true 7 | # Enable pretty backtrace (sets RUST_BACKTRACE=1) 8 | pretty_backtrace: true 9 | # Log level, options: trace, debug, info, warn or error. 10 | level: debug 11 | # Define the logging format. options: compact, pretty or json 12 | format: compact 13 | # By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries 14 | # Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters. 15 | # override_filter: trace 16 | 17 | # Web server configuration 18 | server: 19 | # Port on which the server will listen. the server binding is 0.0.0.0:{PORT} 20 | port: 5150 21 | # Binding for the server (which interface to bind to) 22 | binding: localhost 23 | # The UI hostname or IP address that mailers will point to. 24 | host: http://localhost 25 | # Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block 26 | middlewares: 27 | 28 | # Worker Configuration 29 | workers: 30 | # specifies the worker mode. Options: 31 | # - BackgroundQueue - Workers operate asynchronously in the background, processing queued. 32 | # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. 33 | # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. 34 | mode: BackgroundAsync 35 | 36 | 37 | # Mailer Configuration. 38 | mailer: 39 | # SMTP mailer configuration. 40 | smtp: 41 | # Enable/Disable smtp mailer. 42 | enable: true 43 | # SMTP server host. e.x localhost, smtp.gmail.com 44 | host: localhost 45 | # SMTP server port 46 | port: 1025 47 | # Use secure connection (SSL/TLS). 48 | secure: false 49 | # auth: 50 | # user: 51 | # password: 52 | # Override the SMTP hello name (default is the machine's hostname) 53 | # hello_name: 54 | 55 | # Initializers Configuration 56 | # initializers: 57 | # oauth2: 58 | # authorization_code: # Authorization code grant type 59 | # - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config. 60 | # ... other fields 61 | initializers: 62 | openapi: 63 | scalar: 64 | url: /scalar 65 | redoc: 66 | url: /redoc 67 | swagger: 68 | url: /swagger 69 | spec_json_url: /api-docs/openapi.json 70 | 71 | # Database Configuration 72 | database: 73 | # Database connection URI 74 | uri: {{ get_env(name="DATABASE_URL", default="sqlite://loco-openapi_development.sqlite?mode=rwc") }} 75 | # When enabled, the sql query will be logged. 76 | enable_logging: false 77 | # Set the timeout duration when acquiring a connection. 78 | connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }} 79 | # Set the idle duration before closing a connection. 80 | idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }} 81 | # Minimum number of connections for a pool. 82 | min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }} 83 | # Maximum number of connections for a pool. 84 | max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }} 85 | # Run migration up when application loaded 86 | auto_migrate: true 87 | # Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode 88 | dangerously_truncate: false 89 | # Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode 90 | dangerously_recreate: false 91 | 92 | # Authentication Configuration 93 | auth: 94 | # JWT authentication 95 | jwt: 96 | # Secret key for token generation and verification 97 | secret: bGmCQYHLRCiJ5g75TukQ 98 | # Token expiration time in seconds 99 | expiration: 604800 # 7 days 100 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use axum::Router as AxumRouter; 3 | use loco_rs::prelude::*; 4 | use utoipa::openapi::OpenApi; 5 | use utoipa_axum::router::OpenApiRouter; 6 | #[cfg(feature = "redoc")] 7 | use utoipa_redoc::{Redoc, Servable}; 8 | #[cfg(feature = "scalar")] 9 | use utoipa_scalar::{Scalar, Servable as ScalarServable}; 10 | #[cfg(feature = "swagger")] 11 | use utoipa_swagger_ui::SwaggerUi; 12 | 13 | use crate::config::{get_openapi_config, set_openapi_config, InitializerConfig}; 14 | use crate::openapi::get_merged_router; 15 | // Always used 16 | use crate::utils::set_openapi_spec; 17 | // Only used in feature blocks 18 | #[cfg(any(feature = "redoc", feature = "scalar", feature = "swagger"))] 19 | use crate::utils::{add_openapi_endpoints, get_openapi_spec}; 20 | 21 | pub mod auth; 22 | pub mod config; 23 | pub mod openapi; 24 | pub mod prelude; 25 | pub mod utils; 26 | 27 | type RouterList = Option>>; 28 | type InitialSpec = dyn Fn(&AppContext) -> OpenApi + Send + Sync + 'static; 29 | 30 | /// Loco initializer for `OpenAPI` with custom initial spec setup 31 | #[derive(Default)] 32 | pub struct OpenapiInitializerWithSetup { 33 | /// Custom setup for the initial `OpenAPI` spec, if any 34 | initial_spec: Option>, 35 | /// Routes to add to the `OpenAPI` spec 36 | routes_setup: RouterList, 37 | } 38 | 39 | impl OpenapiInitializerWithSetup { 40 | #[must_use] 41 | pub fn new(initial_spec: F, routes_setup: RouterList) -> Self 42 | where 43 | F: Fn(&AppContext) -> OpenApi + Send + Sync + 'static, 44 | { 45 | Self { 46 | initial_spec: Some(Box::new(initial_spec)), 47 | routes_setup, 48 | } 49 | } 50 | } 51 | 52 | #[async_trait] 53 | impl Initializer for OpenapiInitializerWithSetup { 54 | fn name(&self) -> String { 55 | "openapi".to_string() 56 | } 57 | 58 | async fn after_routes(&self, router: AxumRouter, ctx: &AppContext) -> Result { 59 | // Use the InitializerConfig wrapper 60 | set_openapi_config(InitializerConfig::from(&ctx.config.initializers).into())?; 61 | 62 | let mut api_router: OpenApiRouter = self 63 | .initial_spec 64 | .as_ref() 65 | .map_or_else(OpenApiRouter::new, |custom_spec_fn| { 66 | OpenApiRouter::with_openapi(custom_spec_fn(ctx)) 67 | }); 68 | 69 | // Merge all manually collected routes 70 | if let Some(ref routes_setup) = self.routes_setup { 71 | for route in routes_setup { 72 | api_router = api_router.merge(route.clone()); 73 | } 74 | } 75 | 76 | // Merge all automatically collected routes 77 | api_router = api_router.merge(get_merged_router()); 78 | 79 | // Collect the `OpenAPI` spec 80 | let (_, open_api_spec) = api_router.split_for_parts(); 81 | set_openapi_spec(open_api_spec); 82 | 83 | // Use `_` prefix as config might be unused if no features are enabled 84 | let Some(_open_api_config) = get_openapi_config() else { 85 | // No config, return original router 86 | return Ok(router); 87 | }; 88 | 89 | // Create a new router for UI endpoints 90 | #[allow(unused_mut)] 91 | let mut ui_router = AxumRouter::new(); 92 | 93 | // Serve the `OpenAPI` spec using the enabled `OpenAPI` visualizers 94 | #[cfg(feature = "redoc")] 95 | if let Some(config::OpenAPIType::Redoc { 96 | url, 97 | spec_json_url, 98 | spec_yaml_url, 99 | }) = get_openapi_config().and_then(|c| c.redoc.as_ref()) 100 | { 101 | ui_router = ui_router.merge(Redoc::with_url(url, get_openapi_spec().clone())); 102 | ui_router = add_openapi_endpoints(ui_router, spec_json_url, spec_yaml_url); 103 | } 104 | 105 | #[cfg(feature = "scalar")] 106 | if let Some(config::OpenAPIType::Scalar { 107 | url, 108 | spec_json_url, 109 | spec_yaml_url, 110 | }) = get_openapi_config().and_then(|c| c.scalar.as_ref()) 111 | { 112 | ui_router = ui_router.merge(Scalar::with_url(url, get_openapi_spec().clone())); 113 | ui_router = add_openapi_endpoints(ui_router, spec_json_url, spec_yaml_url); 114 | } 115 | 116 | #[cfg(feature = "swagger")] 117 | if let Some(config::OpenAPIType::Swagger { 118 | url, 119 | spec_json_url, 120 | spec_yaml_url, 121 | }) = get_openapi_config().and_then(|c| c.swagger.as_ref()) 122 | { 123 | ui_router = ui_router 124 | .merge(SwaggerUi::new(url).url(spec_json_url.clone(), get_openapi_spec().clone())); 125 | ui_router = add_openapi_endpoints(ui_router, &None, spec_yaml_url); 126 | } 127 | 128 | // Merge the UI router with the main router 129 | Ok(router.merge(ui_router)) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- 1 | use std::sync::OnceLock; 2 | 3 | use utoipa::{ 4 | openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}, 5 | Modify, 6 | }; 7 | 8 | // Import Loco types for conversion 9 | use loco_rs::{app::AppContext, config::JWTLocation as LocoJWTLocation}; 10 | 11 | // Our own JWTLocation enum that doesn't depend on Loco 12 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 13 | pub enum JWTLocation { 14 | #[default] 15 | Bearer, 16 | Query(String), 17 | Cookie(String), 18 | } 19 | 20 | // Implement From trait for conversion from Loco type to our type 21 | impl From<&LocoJWTLocation> for JWTLocation { 22 | fn from(loco_location: &LocoJWTLocation) -> Self { 23 | match loco_location { 24 | LocoJWTLocation::Bearer => Self::Bearer, 25 | LocoJWTLocation::Query { name } => Self::Query(name.clone()), 26 | LocoJWTLocation::Cookie { name } => Self::Cookie(name.clone()), 27 | } 28 | } 29 | } 30 | 31 | // Support conversion from JWTLocationConfig (Single/Multiple) to JWTLocation 32 | impl From<&loco_rs::config::JWTLocationConfig> for JWTLocation { 33 | fn from(cfg: &loco_rs::config::JWTLocationConfig) -> Self { 34 | match cfg { 35 | loco_rs::config::JWTLocationConfig::Single(loc) => Self::from(loc), 36 | loco_rs::config::JWTLocationConfig::Multiple(locs) => { 37 | locs.first().map_or(Self::Bearer, Self::from) 38 | } 39 | } 40 | } 41 | } 42 | 43 | // Direct conversion from AppContext to JWTLocation for ease of use 44 | impl From<&AppContext> for JWTLocation { 45 | fn from(ctx: &AppContext) -> Self { 46 | ctx.config 47 | .auth 48 | .as_ref() 49 | .and_then(|auth| auth.jwt.as_ref()) 50 | .and_then(|jwt| jwt.location.as_ref()) 51 | .map_or(Self::Bearer, Self::from) 52 | } 53 | } 54 | 55 | static JWT_LOCATION: OnceLock> = OnceLock::new(); 56 | 57 | // Main API for working with JWT location - independent from Loco 58 | pub fn set_jwt_location(jwt_location: JWTLocation) -> &'static Option { 59 | JWT_LOCATION.get_or_init(|| Some(jwt_location)) 60 | } 61 | 62 | pub fn get_jwt_location() -> Option<&'static JWTLocation> { 63 | JWT_LOCATION.get().unwrap_or(&None).as_ref() 64 | } 65 | 66 | // Security implementation using our JWTLocation 67 | pub struct SecurityAddon; 68 | 69 | impl Modify for SecurityAddon { 70 | fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { 71 | if let Some(jwt_location) = get_jwt_location() { 72 | if let Some(components) = openapi.components.as_mut() { 73 | components.add_security_schemes_from_iter([ 74 | ( 75 | "jwt_token", 76 | match jwt_location { 77 | JWTLocation::Bearer => SecurityScheme::Http( 78 | HttpBuilder::new() 79 | .scheme(HttpAuthScheme::Bearer) 80 | .bearer_format("JWT") 81 | .build(), 82 | ), 83 | JWTLocation::Query(name) => { 84 | SecurityScheme::ApiKey(ApiKey::Query(ApiKeyValue::new(name))) 85 | } 86 | JWTLocation::Cookie(name) => { 87 | SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new(name))) 88 | } 89 | }, 90 | ), 91 | ( 92 | "api_key", 93 | SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("apikey"))), 94 | ), 95 | ]); 96 | } 97 | } 98 | } 99 | } 100 | 101 | #[cfg(test)] 102 | mod tests { 103 | use super::*; 104 | 105 | #[test] 106 | fn test_default_jwt_location() { 107 | assert_eq!(JWTLocation::default(), JWTLocation::Bearer); 108 | } 109 | 110 | #[test] 111 | fn test_set_get_jwt_location() { 112 | set_jwt_location(JWTLocation::Bearer); 113 | assert_eq!(get_jwt_location(), Some(&JWTLocation::Bearer)); 114 | } 115 | 116 | #[test] 117 | fn test_from_loco_jwt_location() { 118 | let loco_bearer = LocoJWTLocation::Bearer; 119 | assert_eq!(JWTLocation::from(&loco_bearer), JWTLocation::Bearer); 120 | 121 | let loco_query = LocoJWTLocation::Query { 122 | name: "token".to_string(), 123 | }; 124 | assert_eq!( 125 | JWTLocation::from(&loco_query), 126 | JWTLocation::Query("token".to_string()) 127 | ); 128 | 129 | let loco_cookie = LocoJWTLocation::Cookie { 130 | name: "auth".to_string(), 131 | }; 132 | assert_eq!( 133 | JWTLocation::from(&loco_cookie), 134 | JWTLocation::Cookie("auth".to_string()) 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tests/ui/reqeust.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use loco_openapi::openapi::clear_routes; 3 | use loco_openapi::prelude::routes; 4 | use loco_openapi::{ 5 | auth::{set_jwt_location, SecurityAddon}, 6 | prelude::openapi, // Make sure openapi macro is imported 7 | }; 8 | use loco_rs::{ 9 | app::{AppContext, Hooks, Initializer}, 10 | boot::{create_app, BootResult, StartMode}, 11 | config::Config, 12 | controller::AppRoutes, 13 | environment::Environment, 14 | prelude::*, 15 | task::Tasks, 16 | }; 17 | use rstest::rstest; 18 | use serde::Serialize; // Added import for Album 19 | use serde_json::{json, Value}; 20 | use std::collections::BTreeMap; 21 | use utoipa::{OpenApi, ToSchema}; // Added ToSchema 22 | // Define a minimal TestApp 23 | use insta::{assert_json_snapshot, assert_snapshot, assert_yaml_snapshot, with_settings}; 24 | struct TestApp; 25 | 26 | // --- Start: Embedded Album Controller --- 27 | mod album { 28 | use super::*; // Allow using imports from parent module 29 | use axum::debug_handler; 30 | use axum::routing::get; 31 | 32 | #[derive(Serialize, Debug, ToSchema)] 33 | pub struct Album { 34 | title: String, 35 | rating: u32, 36 | } 37 | 38 | /// Get album 39 | /// 40 | /// Returns a title and rating 41 | #[utoipa::path( 42 | get, 43 | path = "/api/album/get_album", 44 | tags = ["album"], 45 | responses( 46 | (status = 200, description = "Album found", body = Album), 47 | ), 48 | )] 49 | #[debug_handler] 50 | pub async fn get_album(State(_ctx): State) -> Result { 51 | format::json(Album { 52 | title: "VH II".to_string(), 53 | rating: 10, 54 | }) 55 | } 56 | 57 | pub fn routes() -> Routes { 58 | Routes::new() 59 | .prefix("api/album") 60 | .add("/get_album", openapi(get(get_album), routes!(get_album))) 61 | } 62 | } 63 | // --- End: Embedded Album Controller --- 64 | 65 | // Helper to create test configuration 66 | fn config_test() -> Config { 67 | let mut config = loco_rs::tests_cfg::config::test_config(); 68 | let mut initializers = BTreeMap::new(); 69 | let mut openapi_conf = serde_json::Map::new(); 70 | 71 | // Configure endpoints to match test requests 72 | openapi_conf.insert( 73 | "redoc".to_string(), 74 | json!({ 75 | "url": "/redoc", 76 | "spec_json_url": "/redoc/openapi.json", 77 | "spec_yaml_url": "/redoc/openapi.yaml" 78 | }), 79 | ); 80 | openapi_conf.insert( 81 | "scalar".to_string(), 82 | json!({ 83 | "url": "/scalar", 84 | "spec_json_url": "/scalar/openapi.json", 85 | "spec_yaml_url": "/scalar/openapi.yaml" 86 | }), 87 | ); 88 | openapi_conf.insert( 89 | "swagger".to_string(), 90 | json!({ 91 | "url": "/swagger", // Ensure this matches the test URL 92 | "spec_json_url": "/swagger/openapi.json", // Required for swagger 93 | "spec_yaml_url": "/swagger/openapi.yaml" 94 | }), 95 | ); 96 | 97 | initializers.insert("openapi".to_string(), Value::Object(openapi_conf)); 98 | config.initializers = Some(initializers); 99 | config 100 | } 101 | 102 | // Implement Hooks for TestApp 103 | #[async_trait] 104 | impl Hooks for TestApp { 105 | fn app_name() -> &'static str { 106 | "loco-openapi-test" 107 | } 108 | 109 | fn app_version() -> String { 110 | env!("CARGO_PKG_VERSION").to_string() 111 | } 112 | 113 | fn routes(_ctx: &AppContext) -> AppRoutes { 114 | AppRoutes::with_default_routes().add_route(album::routes()) // Add album routes 115 | } 116 | 117 | async fn load_config(_environment: &Environment) -> Result { 118 | Ok(config_test()) 119 | } 120 | 121 | async fn initializers(_ctx: &AppContext) -> Result>> { 122 | Ok(vec![Box::new( 123 | loco_openapi::OpenapiInitializerWithSetup::new( 124 | |ctx| { 125 | #[derive(OpenApi)] 126 | #[openapi( 127 | modifiers(&SecurityAddon), 128 | info( 129 | title = "Loco Demo Test", 130 | description = "Test OpenAPI spec for loco-openapi" 131 | ) 132 | )] 133 | struct ApiDoc; 134 | set_jwt_location(ctx.into()); 135 | 136 | ApiDoc::openapi() 137 | }, 138 | None, 139 | ), 140 | )]) 141 | } 142 | 143 | async fn boot( 144 | mode: StartMode, 145 | environment: &Environment, 146 | config: Config, 147 | ) -> Result { 148 | // Assuming Migrator is not needed as per previous iteration 149 | create_app::(mode, environment, config).await 150 | } 151 | 152 | async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { 153 | Ok(()) 154 | } 155 | 156 | fn register_tasks(_tasks: &mut Tasks) {} 157 | 158 | // Removed truncate and seed as they are not part of the Hooks trait 159 | } 160 | 161 | // Test for OpenAPI UI Endpoints 162 | #[rstest] 163 | #[cfg_attr( 164 | feature = "redoc", 165 | case("/redoc"), 166 | case("/redoc/openapi.json"), 167 | case("/redoc/openapi.yaml") 168 | )] 169 | #[cfg_attr( 170 | feature = "scalar", 171 | case("/scalar"), 172 | case("/scalar/openapi.json"), 173 | case("/scalar/openapi.yaml") 174 | )] 175 | #[cfg_attr( 176 | feature = "swagger", 177 | case("/swagger/"), 178 | case("/swagger/openapi.json"), 179 | case("/swagger/openapi.yaml") 180 | )] 181 | #[case("")] 182 | #[tokio::test] 183 | #[serial_test::serial] 184 | async fn test_openapi_ui_endpoints(#[case] endpoint: &str) { 185 | loco_rs::testing::request::request::(|rq, _ctx| async move { 186 | if endpoint.is_empty() { 187 | return; 188 | } 189 | let res = rq.get(endpoint).await; 190 | 191 | assert_eq!( 192 | res.status_code(), 193 | 200, 194 | "Expected /{} to return 200 OK: {}", 195 | endpoint, 196 | res.text() 197 | ); 198 | 199 | let content_type = res.headers().get("content-type").unwrap().to_str().unwrap(); 200 | match content_type { 201 | "text/html" | "text/html; charset=utf-8" => { 202 | with_settings!({filters => vec![ 203 | (r#"("version":\s*")\d+\.\d+\.\d+"#, "$1[version]"), 204 | ]}, { 205 | assert_snapshot!(format!("[{endpoint}]"), res.text()); 206 | }); 207 | } 208 | "application/json" => { 209 | let json_value = res.json::(); 210 | assert_json_snapshot!(format!("[{endpoint}]"), json_value, {".info.version" => "[version]"}); 211 | } 212 | "application/yaml" => { 213 | let yaml_value = serde_yaml::from_str::(&res.text()).unwrap(); 214 | assert_yaml_snapshot!(format!("[{endpoint}]"), yaml_value, {".info.version" => "[version]"}); 215 | } 216 | _ => panic!("Invalid content type {}", content_type), 217 | } 218 | }) 219 | .await; 220 | clear_routes(); 221 | } 222 | -------------------------------------------------------------------------------- /example/src/controllers/auth.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | mailers::auth::AuthMailer, 3 | models::{ 4 | _entities::users, 5 | users::{LoginParams, RegisterParams}, 6 | }, 7 | views::auth::{CurrentResponse, LoginResponse}, 8 | }; 9 | use axum::debug_handler; 10 | use loco_rs::prelude::*; 11 | use regex::Regex; 12 | use serde::{Deserialize, Serialize}; 13 | use std::sync::OnceLock; 14 | 15 | pub static EMAIL_DOMAIN_RE: OnceLock = OnceLock::new(); 16 | 17 | fn get_allow_email_domain_re() -> &'static Regex { 18 | EMAIL_DOMAIN_RE.get_or_init(|| { 19 | Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex") 20 | }) 21 | } 22 | 23 | #[derive(Debug, Deserialize, Serialize)] 24 | pub struct ForgotParams { 25 | pub email: String, 26 | } 27 | 28 | #[derive(Debug, Deserialize, Serialize)] 29 | pub struct ResetParams { 30 | pub token: String, 31 | pub password: String, 32 | } 33 | 34 | #[derive(Debug, Deserialize, Serialize)] 35 | pub struct MagicLinkParams { 36 | pub email: String, 37 | } 38 | 39 | /// Register function creates a new user with the given parameters and sends a 40 | /// welcome email to the user 41 | #[debug_handler] 42 | async fn register( 43 | State(ctx): State, 44 | Json(params): Json, 45 | ) -> Result { 46 | let res = users::Model::create_with_password(&ctx.db, ¶ms).await; 47 | 48 | let user = match res { 49 | Ok(user) => user, 50 | Err(err) => { 51 | tracing::info!( 52 | message = err.to_string(), 53 | user_email = ¶ms.email, 54 | "could not register user", 55 | ); 56 | return format::json(()); 57 | } 58 | }; 59 | 60 | let user = user 61 | .into_active_model() 62 | .set_email_verification_sent(&ctx.db) 63 | .await?; 64 | 65 | AuthMailer::send_welcome(&ctx, &user).await?; 66 | 67 | format::json(()) 68 | } 69 | 70 | /// Verify register user. if the user not verified his email, he can't login to 71 | /// the system. 72 | #[debug_handler] 73 | async fn verify(State(ctx): State, Path(token): Path) -> Result { 74 | let user = users::Model::find_by_verification_token(&ctx.db, &token).await?; 75 | 76 | if user.email_verified_at.is_some() { 77 | tracing::info!(pid = user.pid.to_string(), "user already verified"); 78 | } else { 79 | let active_model = user.into_active_model(); 80 | let user = active_model.verified(&ctx.db).await?; 81 | tracing::info!(pid = user.pid.to_string(), "user verified"); 82 | } 83 | 84 | format::json(()) 85 | } 86 | 87 | /// In case the user forgot his password this endpoints generate a forgot token 88 | /// and send email to the user. In case the email not found in our DB, we are 89 | /// returning a valid request for for security reasons (not exposing users DB 90 | /// list). 91 | #[debug_handler] 92 | async fn forgot( 93 | State(ctx): State, 94 | Json(params): Json, 95 | ) -> Result { 96 | let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { 97 | // we don't want to expose our users email. if the email is invalid we still 98 | // returning success to the caller 99 | return format::json(()); 100 | }; 101 | 102 | let user = user 103 | .into_active_model() 104 | .set_forgot_password_sent(&ctx.db) 105 | .await?; 106 | 107 | AuthMailer::forgot_password(&ctx, &user).await?; 108 | 109 | format::json(()) 110 | } 111 | 112 | /// reset user password by the given parameters 113 | #[debug_handler] 114 | async fn reset(State(ctx): State, Json(params): Json) -> Result { 115 | let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else { 116 | // we don't want to expose our users email. if the email is invalid we still 117 | // returning success to the caller 118 | tracing::info!("reset token not found"); 119 | 120 | return format::json(()); 121 | }; 122 | user.into_active_model() 123 | .reset_password(&ctx.db, ¶ms.password) 124 | .await?; 125 | 126 | format::json(()) 127 | } 128 | 129 | /// Creates a user login and returns a token 130 | #[debug_handler] 131 | async fn login(State(ctx): State, Json(params): Json) -> Result { 132 | let user = users::Model::find_by_email(&ctx.db, ¶ms.email).await?; 133 | 134 | let valid = user.verify_password(¶ms.password); 135 | 136 | if !valid { 137 | return unauthorized("unauthorized!"); 138 | } 139 | 140 | let jwt_secret = ctx.config.get_jwt_config()?; 141 | 142 | let token = user 143 | .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) 144 | .or_else(|_| unauthorized("unauthorized!"))?; 145 | 146 | format::json(LoginResponse::new(&user, &token)) 147 | } 148 | 149 | #[debug_handler] 150 | async fn current(auth: auth::JWT, State(ctx): State) -> Result { 151 | let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; 152 | format::json(CurrentResponse::new(&user)) 153 | } 154 | 155 | /// Magic link authentication provides a secure and passwordless way to log in to the application. 156 | /// 157 | /// # Flow 158 | /// 1. **Request a Magic Link**: 159 | /// A registered user sends a POST request to `/magic-link` with their email. 160 | /// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email. 161 | /// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid. 162 | /// 163 | /// 2. **Click the Magic Link**: 164 | /// The user clicks the link (/magic-link/{token}), which validates the token and its expiration. 165 | /// If valid, the server generates a JWT and responds with a [`LoginResponse`]. 166 | /// If invalid or expired, an unauthorized response is returned. 167 | /// 168 | /// This flow enhances security by avoiding traditional passwords and providing a seamless login experience. 169 | async fn magic_link( 170 | State(ctx): State, 171 | Json(params): Json, 172 | ) -> Result { 173 | let email_regex = get_allow_email_domain_re(); 174 | if !email_regex.is_match(¶ms.email) { 175 | tracing::debug!( 176 | email = params.email, 177 | "The provided email is invalid or does not match the allowed domains" 178 | ); 179 | return bad_request("invalid request"); 180 | } 181 | 182 | let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { 183 | // we don't want to expose our users email. if the email is invalid we still 184 | // returning success to the caller 185 | tracing::debug!(email = params.email, "user not found by email"); 186 | return format::empty_json(); 187 | }; 188 | 189 | let user = user.into_active_model().create_magic_link(&ctx.db).await?; 190 | AuthMailer::send_magic_link(&ctx, &user).await?; 191 | 192 | format::empty_json() 193 | } 194 | 195 | /// Verifies a magic link token and authenticates the user. 196 | async fn magic_link_verify( 197 | Path(token): Path, 198 | State(ctx): State, 199 | ) -> Result { 200 | let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else { 201 | // we don't want to expose our users email. if the email is invalid we still 202 | // returning success to the caller 203 | return unauthorized("unauthorized!"); 204 | }; 205 | 206 | let user = user.into_active_model().clear_magic_link(&ctx.db).await?; 207 | 208 | let jwt_secret = ctx.config.get_jwt_config()?; 209 | 210 | let token = user 211 | .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) 212 | .or_else(|_| unauthorized("unauthorized!"))?; 213 | 214 | format::json(LoginResponse::new(&user, &token)) 215 | } 216 | 217 | pub fn routes() -> Routes { 218 | Routes::new() 219 | .prefix("/api/auth") 220 | .add("/register", post(register)) 221 | .add("/verify/{token}", get(verify)) 222 | .add("/login", post(login)) 223 | .add("/forgot", post(forgot)) 224 | .add("/reset", post(reset)) 225 | .add("/current", get(current)) 226 | .add("/magic-link", post(magic_link)) 227 | .add("/magic-link/{token}", get(magic_link_verify)) 228 | } 229 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `loco-openapi-initializer` 2 | [![crate](https://img.shields.io/crates/v/loco-openapi.svg)](https://crates.io/crates/loco-openapi) 3 | 4 | This crate adds OpenAPI support to Loco by using a initializer. 5 | 6 | The Loco OpenAPI integration is generated using [`Utoipa`](https://github.com/juhaku/utoipa) 7 | 8 | # Installation 9 | 10 | ## `Cargo.toml` 11 | 12 | Edit your `Cargo.toml` file 13 | 14 | Add the `loco-openapi` initializer, with one or multiple of the following features flags: 15 | 16 | - `swagger` 17 | - `redoc` 18 | - `scalar` 19 | - `full` 20 | 21 | ### Example 22 | 23 | ```toml 24 | # Cargo.toml 25 | [dependencies] 26 | loco-openapi = { version = "0.1", features = ["full"] } 27 | ``` 28 | 29 | ## Configuration 30 | 31 | Add the corresponding OpenAPI visualizer to the config file 32 | 33 | ```yaml 34 | # config/*.yaml 35 | #... 36 | initializers: 37 | openapi: 38 | redoc: 39 | url: /redoc 40 | # spec_json_url: /redoc/openapi.json 41 | # spec_yaml_url: /redoc/openapi.yaml 42 | scalar: 43 | url: /scalar 44 | # spec_json_url: /scalar/openapi.json 45 | # spec_yaml_url: /scalar/openapi.yaml 46 | swagger: 47 | url: /swagger 48 | spec_json_url: /api-docs/openapi.json # spec_json_url is required for swagger-ui 49 | # spec_yaml_url: /api-docs/openapi.yaml 50 | ``` 51 | 52 | ## Adding the OpenAPI initializer 53 | 54 | In the initializer you can modify the OpenAPI spec before the routes are added, allowing you to edit [`openapi::info`](https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html) 55 | 56 | ```rust 57 | // src/app.rs 58 | use loco_openapi::prelude::*; 59 | 60 | async fn initializers(ctx: &AppContext) -> Result>> { 61 | Ok(vec![Box::new( 62 | loco_openapi::OpenapiInitializerWithSetup::new( 63 | |ctx| { 64 | #[derive(OpenApi)] 65 | #[openapi( 66 | modifiers(&SecurityAddon), 67 | info( 68 | title = "Loco Demo", 69 | description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." 70 | ) 71 | )] 72 | struct ApiDoc; 73 | set_jwt_location(ctx.into()); 74 | 75 | ApiDoc::openapi() 76 | }, 77 | // When using automatic schema collection only 78 | None, 79 | // When using manual schema collection 80 | // Manual schema collection can also be used at the same time as automatic schema collection 81 | // Some(vec![controllers::album::api_routes()]), 82 | ), 83 | )]) 84 | } 85 | ``` 86 | 87 | # Usage 88 | 89 | ## Generating the OpenAPI spec 90 | 91 | Only routes that are annotated with [`utoipa::path`](https://docs.rs/utoipa/latest/utoipa/attr.path.html) will be included in the OpenAPI spec. 92 | 93 | ```rust 94 | use loco_openapi::prelude::*; 95 | 96 | /// Your Title here 97 | /// 98 | /// Your Description here 99 | #[utoipa::path( 100 | get, 101 | path = "/api/album/get_album", 102 | responses( 103 | (status = 200, description = "Album found", body = Album), 104 | ), 105 | )] 106 | async fn get_action_openapi() -> Result { 107 | format::json(Album { 108 | title: "VH II".to_string(), 109 | rating: 10, 110 | }) 111 | } 112 | ``` 113 | 114 | ### `#[derive(ToSchema)]` 115 | 116 | Make sure to add `#[derive(ToSchema)]` on any struct that included in [`utoipa::path`](https://docs.rs/utoipa/latest/utoipa/attr.path.html). 117 | 118 | ```rust 119 | use loco_openapi::prelude::*; 120 | 121 | #[derive(Serialize, Debug, ToSchema)] 122 | pub struct Album { 123 | title: String, 124 | rating: u32, 125 | } 126 | ``` 127 | 128 | ## Automatically adding routes to the OpenAPI spec visualizer 129 | 130 | Swap `axum::routing::MethodRouter` to `openapi(MethodRouter, UtoipaMethodRouter)` 131 | 132 | ```diff 133 | + use loco_openapi::prelude::*; 134 | 135 | Routes::new() 136 | .prefix("api/album/") 137 | - .add("/get_album", get(get_album)), 138 | + .add("/get_album", openapi(get(get_album), routes!(get_album))), 139 | ``` 140 | 141 | In the initializer, if you are only using automatic schema collection, set the second arg to `None`, to disable manual schema collection 142 | 143 | ```rust 144 | use loco_openapi::prelude::*; 145 | 146 | async fn initializers(ctx: &AppContext) -> Result>> { 147 | Ok(vec![Box::new( 148 | loco_openapi::OpenapiInitializerWithSetup::new( 149 | |ctx| { 150 | // ... 151 | }, 152 | None, 153 | ), 154 | )]) 155 | } 156 | ``` 157 | 158 | ### Note: do not add multiple routes inside the `routes!` macro 159 | 160 | ```rust 161 | .add("/get_album", openapi(get(get_album), routes!(get_action_1_do_not_do_this, get_action_2_do_not_do_this))), 162 | ``` 163 | 164 | ## Manualy adding routes to the OpenAPI spec visualizer 165 | 166 | Create a function that returns `OpenApiRouter` 167 | 168 | ```rust 169 | use loco_openapi::prelude::*; 170 | 171 | pub fn routes() -> Routes { 172 | Routes::new() 173 | .prefix("api/album/") 174 | .add("/get_album", get(get_album)) 175 | } 176 | 177 | pub fn api_routes() -> OpenApiRouter { 178 | OpenApiRouter::new().routes(routes!(get_album)) 179 | } 180 | ``` 181 | 182 | Then in the initializer, create a `Vec>` 183 | 184 | ```rust 185 | use loco_openapi::prelude::*; 186 | 187 | async fn initializers(ctx: &AppContext) -> Result>> { 188 | Ok(vec![Box::new( 189 | loco_openapi::OpenapiInitializerWithSetup::new( 190 | |ctx| { 191 | // ... 192 | }, 193 | Some(vec![controllers::album::api_routes()]), 194 | ), 195 | )]) 196 | } 197 | ``` 198 | 199 | ## Using both manual and automatic schema collection 200 | 201 | It's possible to use both manual and automatic schema collection at the same time. But make sure to only add each route once. 202 | 203 | Using both of the examples above, at the same time will not work, since the route `/get_album` will be added twice. 204 | 205 | ```rust 206 | // controllers 207 | use loco_openapi::prelude::*; 208 | 209 | pub fn routes() -> Routes { 210 | Routes::new() 211 | .prefix("api/album/") 212 | // automatic schema collection of `/get_album` is here 213 | .add("/get_album", openapi(get(get_album), routes!(get_album))), 214 | } 215 | 216 | pub fn api_routes() -> OpenApiRouter { 217 | // OpenApiRouter for manual schema collection of `/get_album` is created here 218 | OpenApiRouter::new().routes(routes!(get_album)) 219 | } 220 | ``` 221 | 222 | ```rust 223 | // initializers 224 | use loco_openapi::prelude::*; 225 | 226 | async fn initializers(ctx: &AppContext) -> Result>> { 227 | Ok(vec![Box::new( 228 | loco_openapi::OpenapiInitializerWithSetup::new( 229 | |ctx| { 230 | // ... 231 | }, 232 | // manual schema collection is added to the OpenAPI spec here 233 | Some(vec![controllers::album::api_routes()]), 234 | ), 235 | )]) 236 | } 237 | ``` 238 | 239 | ### Security Documentation 240 | 241 | If `modifiers(&SecurityAddon)` is set in `inital_openapi_spec`, you can document the per route security in `utoipa::path` by setting the [`SecurityRequirement`](https://docs.rs/utoipa/latest/utoipa/openapi/security/struct.SecurityRequirement.html): 242 | 243 | - `security(("jwt_token" = []))` 244 | - `security(("api_key" = []))` 245 | 246 | To remove security from the route: 247 | 248 | - remove `security` from `utoipa::path` 249 | - or leave empty `security(())` 250 | 251 | Example: 252 | 253 | ```rust 254 | use loco_openapi::prelude::*; 255 | 256 | #[utoipa::path( 257 | get, 258 | path = "/api/album/get_album", 259 | security(("jwt_token" = [])), 260 | responses( 261 | (status = 200, description = "Album found", body = Album), 262 | ), 263 | )] 264 | ``` 265 | 266 | # Available Endpoints 267 | 268 | After running `cargo loco start` the OpenAPI visualizers are available at the following URLs by default: 269 | 270 | - 271 | - 272 | - 273 | 274 | To customize the OpenAPI visualizers URLs,and endpoint paths for json and yaml, see `config/*.yaml`. 275 | 276 | # Testing with `loco-openapi-initializer` installed 277 | 278 | Because of global shared state issues when using automatic schema collection, it's recommended to disable the `loco-openapi-initializer` when running tests in your application. 279 | 280 | ```rust 281 | async fn initializers(ctx: &AppContext) -> Result>> { 282 | let mut initializers: Vec> = vec![]; 283 | 284 | if ctx.environment != Environment::Test { 285 | initializers.push( 286 | Box::new( 287 | loco_openapi::OpenapiInitializerWithSetup::new( 288 | |ctx| { 289 | // ... 290 | }, 291 | None, 292 | ), 293 | ) as Box 294 | ); 295 | } 296 | 297 | Ok(initializers) 298 | } 299 | ``` 300 | 301 | Alternatively you could use [`cargo nextest`](https://nexte.st/). This issue is not relevant when using the `loco-openapi-initializer` for normal use. 302 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use std::sync::OnceLock; 3 | 4 | use loco_rs::Error; 5 | use serde::{Deserialize, Serialize}; 6 | use serde_json::Value; 7 | 8 | static OPENAPI_CONFIG: OnceLock> = OnceLock::new(); 9 | 10 | // Newtype wrapper for initialization config 11 | #[derive(Debug)] 12 | pub struct InitializerConfig<'a>(&'a Option>); 13 | 14 | impl<'a> From<&'a Option>> for InitializerConfig<'a> { 15 | fn from(initializers: &'a Option>) -> Self { 16 | InitializerConfig(initializers) 17 | } 18 | } 19 | 20 | impl<'a> From> for Option { 21 | fn from(config: InitializerConfig<'a>) -> Self { 22 | config 23 | .0 24 | .as_ref() 25 | .and_then(|m| m.get("openapi")) 26 | .cloned() 27 | .and_then(|json| serde_json::from_value(json).ok()) 28 | } 29 | } 30 | 31 | /// Set the `OpenAPI` configuration directly 32 | /// 33 | /// # Errors 34 | /// 35 | /// Will return `Err` if the configuration can't be set 36 | pub fn set_openapi_config( 37 | config: Option, 38 | ) -> Result, Error> { 39 | Ok(OPENAPI_CONFIG.get_or_init(|| config).as_ref()) 40 | } 41 | 42 | pub fn get_openapi_config() -> Option<&'static OpenAPIConfig> { 43 | OPENAPI_CONFIG.get().unwrap_or(&None).as_ref() 44 | } 45 | 46 | /// `OpenAPI` configuration 47 | /// Example: 48 | /// ```yaml 49 | /// initializers: 50 | /// openapi: 51 | /// redoc: 52 | /// url: /redoc 53 | /// # spec_json_url: /redoc/openapi.json 54 | /// # spec_yaml_url: /redoc/openapi.yaml 55 | /// scalar: 56 | /// url: /scalar 57 | /// # spec_json_url: /scalar/openapi.json 58 | /// # spec_yaml_url: /scalar/openapi.yaml 59 | /// swagger: 60 | /// url: /swagger 61 | /// spec_json_url: /api-docs/openapi.json 62 | /// # spec_yaml_url: /api-docs/openapi.yaml 63 | /// ``` 64 | #[derive(Debug, Clone, Deserialize, Serialize)] 65 | #[cfg_attr(test, derive(PartialEq, Eq))] 66 | pub struct OpenAPIConfig { 67 | /// Redoc configuration 68 | /// Example: 69 | /// ```yaml 70 | /// initializers: 71 | /// openapi: 72 | /// redoc: 73 | /// url: /redoc 74 | /// ``` 75 | #[cfg(feature = "redoc")] 76 | #[serde(flatten)] 77 | pub redoc: Option, 78 | /// Scalar configuration 79 | /// Example: 80 | /// ```yaml 81 | /// initializers: 82 | /// openapi: 83 | /// scalar: 84 | /// url: /scalar 85 | /// ``` 86 | #[cfg(feature = "scalar")] 87 | #[serde(flatten)] 88 | pub scalar: Option, 89 | /// Swagger configuration 90 | /// Example: 91 | /// ```yaml 92 | /// initializers: 93 | /// openapi: 94 | /// swagger: 95 | /// url: /swagger 96 | /// spec_json_url: /openapi.json 97 | /// ``` 98 | #[cfg(feature = "swagger")] 99 | #[serde(flatten)] 100 | pub swagger: Option, 101 | } 102 | 103 | /// `OpenAPI` configuration types 104 | #[derive(Debug, Clone, Deserialize, Serialize)] 105 | #[cfg_attr(test, derive(PartialEq, Eq))] 106 | pub enum OpenAPIType { 107 | /// Redoc configuration 108 | /// Example: 109 | /// ```yaml 110 | /// initializers: 111 | /// openapi: 112 | /// redoc: 113 | /// url: /redoc 114 | /// ``` 115 | #[cfg(feature = "redoc")] 116 | #[serde(rename = "redoc")] 117 | Redoc { 118 | /// URL for where to host the redoc `OpenAPI` spec, example: /redoc 119 | url: String, 120 | /// URL for openapi.json, for example: /openapi.json 121 | spec_json_url: Option, 122 | /// URL for openapi.yaml, for example: /openapi.yaml 123 | spec_yaml_url: Option, 124 | }, 125 | /// Scalar configuration 126 | /// Example: 127 | /// ```yaml 128 | /// initializers: 129 | /// openapi: 130 | /// scalar: 131 | /// url: /scalar 132 | /// ``` 133 | #[cfg(feature = "scalar")] 134 | #[serde(rename = "scalar")] 135 | Scalar { 136 | /// URL for where to host the scalar `OpenAPI` spec, example: /scalar 137 | url: String, 138 | /// URL for openapi.json, for example: /openapi.json 139 | spec_json_url: Option, 140 | /// URL for openapi.yaml, for example: /openapi.yaml 141 | spec_yaml_url: Option, 142 | }, 143 | /// Swagger configuration 144 | /// Example: 145 | /// ```yaml 146 | /// initializers: 147 | /// openapi: 148 | /// swagger: 149 | /// url: /swagger 150 | /// spec_json_url: /openapi.json 151 | /// ``` 152 | #[cfg(feature = "swagger")] 153 | #[serde(rename = "swagger")] 154 | Swagger { 155 | /// URL for where to host the swagger `OpenAPI` spec, example: 156 | /// /swagger-ui 157 | url: String, 158 | /// URL for openapi.json, for example: /api-docs/openapi.json 159 | spec_json_url: String, 160 | /// URL for openapi.yaml, for example: /openapi.yaml 161 | spec_yaml_url: Option, 162 | }, 163 | } 164 | 165 | #[cfg(test)] 166 | mod tests { 167 | use super::*; 168 | #[cfg(any(feature = "swagger", feature = "redoc", feature = "scalar"))] 169 | use serde_json::json; 170 | 171 | // Helper function to create a mock configuration 172 | #[cfg(any(feature = "swagger", feature = "redoc", feature = "scalar"))] 173 | fn create_mock_config() -> BTreeMap { 174 | let mut config = BTreeMap::new(); 175 | 176 | // Create OpenAPI config JSON 177 | let mut openapi_config = serde_json::Map::new(); 178 | 179 | // Add swagger config conditionally 180 | #[cfg(feature = "swagger")] 181 | { 182 | openapi_config.insert( 183 | "swagger".to_string(), 184 | json!({ 185 | "url": "/swagger", 186 | "spec_json_url": "/api-docs/openapi.json" 187 | }), 188 | ); 189 | } 190 | 191 | // Add redoc config conditionally 192 | #[cfg(feature = "redoc")] 193 | { 194 | openapi_config.insert( 195 | "redoc".to_string(), 196 | json!({ 197 | "url": "/redoc", 198 | "spec_json_url": "/redoc/openapi.json", 199 | "spec_yaml_url": "/redoc/openapi.yaml" 200 | }), 201 | ); 202 | } 203 | 204 | // Add scalar config conditionally 205 | #[cfg(feature = "scalar")] 206 | { 207 | openapi_config.insert( 208 | "scalar".to_string(), 209 | json!({ 210 | "url": "/scalar", 211 | "spec_json_url": "/scalar/openapi.json", 212 | "spec_yaml_url": "/scalar/openapi.yaml" 213 | }), 214 | ); 215 | } 216 | 217 | config.insert("openapi".to_string(), Value::Object(openapi_config)); 218 | config 219 | } 220 | 221 | #[test] 222 | #[cfg(any(feature = "swagger", feature = "redoc", feature = "scalar"))] 223 | fn test_data_conversion() { 224 | // Test the conversion pipeline with valid data 225 | let initializers = Some(create_mock_config()); 226 | 227 | // Convert to InitializerConfig and then to OpenAPIConfig 228 | let initializer_config: InitializerConfig = (&initializers).into(); 229 | let openapi_config: Option = initializer_config.into(); 230 | 231 | // Verify the conversion produces the expected result 232 | assert!( 233 | openapi_config.is_some(), 234 | "OpenAPIConfig should be created successfully" 235 | ); 236 | 237 | // Check the values based on enabled features 238 | let config = openapi_config.unwrap(); 239 | 240 | #[cfg(feature = "swagger")] 241 | { 242 | let swagger = config.swagger.as_ref(); 243 | assert!(swagger.is_some(), "Swagger config should be present"); 244 | 245 | let expected = OpenAPIType::Swagger { 246 | url: "/swagger".to_string(), 247 | spec_json_url: "/api-docs/openapi.json".to_string(), 248 | spec_yaml_url: None, 249 | }; 250 | assert_eq!(swagger, Some(&expected)); 251 | } 252 | 253 | #[cfg(feature = "redoc")] 254 | { 255 | let redoc = config.redoc.as_ref(); 256 | assert!(redoc.is_some(), "Redoc config should be present"); 257 | 258 | let expected = OpenAPIType::Redoc { 259 | url: "/redoc".to_string(), 260 | spec_json_url: Some("/redoc/openapi.json".to_string()), 261 | spec_yaml_url: Some("/redoc/openapi.yaml".to_string()), 262 | }; 263 | assert_eq!(redoc, Some(&expected)); 264 | } 265 | 266 | #[cfg(feature = "scalar")] 267 | { 268 | let scalar = config.scalar.as_ref(); 269 | assert!(scalar.is_some(), "Scalar config should be present"); 270 | 271 | let expected = OpenAPIType::Scalar { 272 | url: "/scalar".to_string(), 273 | spec_json_url: Some("/scalar/openapi.json".to_string()), 274 | spec_yaml_url: Some("/scalar/openapi.yaml".to_string()), 275 | }; 276 | assert_eq!(scalar, Some(&expected)); 277 | } 278 | } 279 | 280 | #[test] 281 | fn test_none_conversion() { 282 | // Test with None input 283 | let initializers: Option> = None; 284 | 285 | // Convert to InitializerConfig and then to OpenAPIConfig 286 | let openapi_config: Option = InitializerConfig::from(&initializers).into(); 287 | 288 | // Verify the conversion handles None correctly 289 | assert!(openapi_config.is_none(), "OpenAPIConfig should be None"); 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /example/tests/models/users.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Duration, offset::Local}; 2 | use insta::assert_debug_snapshot; 3 | use loco_openapi_example::{ 4 | app::App, 5 | models::users::{self, Model, RegisterParams}, 6 | }; 7 | use loco_rs::testing::prelude::*; 8 | use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel}; 9 | use serial_test::serial; 10 | 11 | macro_rules! configure_insta { 12 | ($($expr:expr),*) => { 13 | let mut settings = insta::Settings::clone_current(); 14 | settings.set_prepend_module_to_snapshot(false); 15 | settings.set_snapshot_suffix("users"); 16 | let _guard = settings.bind_to_scope(); 17 | }; 18 | } 19 | 20 | #[tokio::test] 21 | #[serial] 22 | async fn test_can_validate_model() { 23 | configure_insta!(); 24 | 25 | let boot = boot_test::() 26 | .await 27 | .expect("Failed to boot test application"); 28 | 29 | let invalid_user = users::ActiveModel { 30 | name: ActiveValue::set("1".to_string()), 31 | email: ActiveValue::set("invalid-email".to_string()), 32 | ..Default::default() 33 | }; 34 | 35 | let res = invalid_user.insert(&boot.app_context.db).await; 36 | 37 | assert_debug_snapshot!(res); 38 | } 39 | 40 | #[tokio::test] 41 | #[serial] 42 | async fn can_create_with_password() { 43 | configure_insta!(); 44 | 45 | let boot = boot_test::() 46 | .await 47 | .expect("Failed to boot test application"); 48 | 49 | let params = RegisterParams { 50 | email: "test@framework.com".to_string(), 51 | password: "1234".to_string(), 52 | name: "framework".to_string(), 53 | }; 54 | 55 | let res = Model::create_with_password(&boot.app_context.db, ¶ms).await; 56 | 57 | insta::with_settings!({ 58 | filters => cleanup_user_model() 59 | }, { 60 | assert_debug_snapshot!(res); 61 | }); 62 | } 63 | #[tokio::test] 64 | #[serial] 65 | async fn handle_create_with_password_with_duplicate() { 66 | configure_insta!(); 67 | 68 | let boot = boot_test::() 69 | .await 70 | .expect("Failed to boot test application"); 71 | seed::(&boot.app_context) 72 | .await 73 | .expect("Failed to seed database"); 74 | 75 | let new_user = Model::create_with_password( 76 | &boot.app_context.db, 77 | &RegisterParams { 78 | email: "user1@example.com".to_string(), 79 | password: "1234".to_string(), 80 | name: "framework".to_string(), 81 | }, 82 | ) 83 | .await; 84 | 85 | assert_debug_snapshot!(new_user); 86 | } 87 | 88 | #[tokio::test] 89 | #[serial] 90 | async fn can_find_by_email() { 91 | configure_insta!(); 92 | 93 | let boot = boot_test::() 94 | .await 95 | .expect("Failed to boot test application"); 96 | seed::(&boot.app_context) 97 | .await 98 | .expect("Failed to seed database"); 99 | 100 | let existing_user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await; 101 | let non_existing_user_results = 102 | Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await; 103 | 104 | assert_debug_snapshot!(existing_user); 105 | assert_debug_snapshot!(non_existing_user_results); 106 | } 107 | 108 | #[tokio::test] 109 | #[serial] 110 | async fn can_find_by_pid() { 111 | configure_insta!(); 112 | 113 | let boot = boot_test::() 114 | .await 115 | .expect("Failed to boot test application"); 116 | seed::(&boot.app_context) 117 | .await 118 | .expect("Failed to seed database"); 119 | 120 | let existing_user = 121 | Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await; 122 | let non_existing_user_results = 123 | Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await; 124 | 125 | assert_debug_snapshot!(existing_user); 126 | assert_debug_snapshot!(non_existing_user_results); 127 | } 128 | 129 | #[tokio::test] 130 | #[serial] 131 | async fn can_verification_token() { 132 | configure_insta!(); 133 | 134 | let boot = boot_test::() 135 | .await 136 | .expect("Failed to boot test application"); 137 | seed::(&boot.app_context) 138 | .await 139 | .expect("Failed to seed database"); 140 | 141 | let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 142 | .await 143 | .expect("Failed to find user by PID"); 144 | 145 | assert!( 146 | user.email_verification_sent_at.is_none(), 147 | "Expected no email verification sent timestamp" 148 | ); 149 | assert!( 150 | user.email_verification_token.is_none(), 151 | "Expected no email verification token" 152 | ); 153 | 154 | let result = user 155 | .into_active_model() 156 | .set_email_verification_sent(&boot.app_context.db) 157 | .await; 158 | 159 | assert!(result.is_ok(), "Failed to set email verification sent"); 160 | 161 | let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 162 | .await 163 | .expect("Failed to find user by PID after setting verification sent"); 164 | 165 | assert!( 166 | user.email_verification_sent_at.is_some(), 167 | "Expected email verification sent timestamp to be present" 168 | ); 169 | assert!( 170 | user.email_verification_token.is_some(), 171 | "Expected email verification token to be present" 172 | ); 173 | } 174 | 175 | #[tokio::test] 176 | #[serial] 177 | async fn can_set_forgot_password_sent() { 178 | configure_insta!(); 179 | 180 | let boot = boot_test::() 181 | .await 182 | .expect("Failed to boot test application"); 183 | seed::(&boot.app_context) 184 | .await 185 | .expect("Failed to seed database"); 186 | 187 | let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 188 | .await 189 | .expect("Failed to find user by PID"); 190 | 191 | assert!( 192 | user.reset_sent_at.is_none(), 193 | "Expected no reset sent timestamp" 194 | ); 195 | assert!(user.reset_token.is_none(), "Expected no reset token"); 196 | 197 | let result = user 198 | .into_active_model() 199 | .set_forgot_password_sent(&boot.app_context.db) 200 | .await; 201 | 202 | assert!(result.is_ok(), "Failed to set forgot password sent"); 203 | 204 | let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 205 | .await 206 | .expect("Failed to find user by PID after setting forgot password sent"); 207 | 208 | assert!( 209 | user.reset_sent_at.is_some(), 210 | "Expected reset sent timestamp to be present" 211 | ); 212 | assert!( 213 | user.reset_token.is_some(), 214 | "Expected reset token to be present" 215 | ); 216 | } 217 | 218 | #[tokio::test] 219 | #[serial] 220 | async fn can_verified() { 221 | configure_insta!(); 222 | 223 | let boot = boot_test::() 224 | .await 225 | .expect("Failed to boot test application"); 226 | seed::(&boot.app_context) 227 | .await 228 | .expect("Failed to seed database"); 229 | 230 | let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 231 | .await 232 | .expect("Failed to find user by PID"); 233 | 234 | assert!( 235 | user.email_verified_at.is_none(), 236 | "Expected email to be unverified" 237 | ); 238 | 239 | let result = user 240 | .into_active_model() 241 | .verified(&boot.app_context.db) 242 | .await; 243 | 244 | assert!(result.is_ok(), "Failed to mark email as verified"); 245 | 246 | let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 247 | .await 248 | .expect("Failed to find user by PID after verification"); 249 | 250 | assert!( 251 | user.email_verified_at.is_some(), 252 | "Expected email to be verified" 253 | ); 254 | } 255 | 256 | #[tokio::test] 257 | #[serial] 258 | async fn can_reset_password() { 259 | configure_insta!(); 260 | 261 | let boot = boot_test::() 262 | .await 263 | .expect("Failed to boot test application"); 264 | seed::(&boot.app_context) 265 | .await 266 | .expect("Failed to seed database"); 267 | 268 | let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 269 | .await 270 | .expect("Failed to find user by PID"); 271 | 272 | assert!( 273 | user.verify_password("12341234"), 274 | "Password verification failed for original password" 275 | ); 276 | 277 | let result = user 278 | .clone() 279 | .into_active_model() 280 | .reset_password(&boot.app_context.db, "new-password") 281 | .await; 282 | 283 | assert!(result.is_ok(), "Failed to reset password"); 284 | 285 | let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 286 | .await 287 | .expect("Failed to find user by PID after password reset"); 288 | 289 | assert!( 290 | user.verify_password("new-password"), 291 | "Password verification failed for new password" 292 | ); 293 | } 294 | 295 | #[tokio::test] 296 | #[serial] 297 | async fn magic_link() { 298 | let boot = boot_test::().await.unwrap(); 299 | seed::(&boot.app_context).await.unwrap(); 300 | 301 | let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 302 | .await 303 | .unwrap(); 304 | 305 | assert!( 306 | user.magic_link_token.is_none(), 307 | "Magic link token should be initially unset" 308 | ); 309 | assert!( 310 | user.magic_link_expiration.is_none(), 311 | "Magic link expiration should be initially unset" 312 | ); 313 | 314 | let create_result = user 315 | .into_active_model() 316 | .create_magic_link(&boot.app_context.db) 317 | .await; 318 | 319 | assert!( 320 | create_result.is_ok(), 321 | "Failed to create magic link: {:?}", 322 | create_result.unwrap_err() 323 | ); 324 | 325 | let updated_user = 326 | Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") 327 | .await 328 | .expect("Failed to refetch user after magic link creation"); 329 | 330 | assert!( 331 | updated_user.magic_link_token.is_some(), 332 | "Magic link token should be set after creation" 333 | ); 334 | 335 | let magic_link_token = updated_user.magic_link_token.unwrap(); 336 | assert_eq!( 337 | magic_link_token.len(), 338 | users::MAGIC_LINK_LENGTH as usize, 339 | "Magic link token length does not match expected length" 340 | ); 341 | 342 | assert!( 343 | updated_user.magic_link_expiration.is_some(), 344 | "Magic link expiration should be set after creation" 345 | ); 346 | 347 | let now = Local::now(); 348 | let should_expired_at = now + Duration::minutes(users::MAGIC_LINK_EXPIRATION_MIN.into()); 349 | let actual_expiration = updated_user.magic_link_expiration.unwrap(); 350 | 351 | assert!( 352 | actual_expiration >= now, 353 | "Magic link expiration should be in the future or now" 354 | ); 355 | 356 | assert!( 357 | actual_expiration <= should_expired_at, 358 | "Magic link expiration exceeds expected maximum expiration time" 359 | ); 360 | } 361 | -------------------------------------------------------------------------------- /example/tests/requests/auth.rs: -------------------------------------------------------------------------------- 1 | use insta::{assert_debug_snapshot, with_settings}; 2 | use loco_openapi_example::{app::App, models::users}; 3 | use loco_rs::testing::prelude::*; 4 | use rstest::rstest; 5 | use serial_test::serial; 6 | 7 | use super::prepare_data; 8 | 9 | // TODO: see how to dedup / extract this to app-local test utils 10 | // not to framework, because that would require a runtime dep on insta 11 | macro_rules! configure_insta { 12 | ($($expr:expr),*) => { 13 | let mut settings = insta::Settings::clone_current(); 14 | settings.set_prepend_module_to_snapshot(false); 15 | settings.set_snapshot_suffix("auth_request"); 16 | let _guard = settings.bind_to_scope(); 17 | }; 18 | } 19 | 20 | #[tokio::test] 21 | #[serial] 22 | async fn can_register() { 23 | configure_insta!(); 24 | 25 | request::(|request, ctx| async move { 26 | let email = "test@loco.com"; 27 | let payload = serde_json::json!({ 28 | "name": "loco", 29 | "email": email, 30 | "password": "12341234" 31 | }); 32 | 33 | let response = request.post("/api/auth/register").json(&payload).await; 34 | assert_eq!( 35 | response.status_code(), 36 | 200, 37 | "Register request should succeed" 38 | ); 39 | let saved_user = users::Model::find_by_email(&ctx.db, email).await; 40 | 41 | with_settings!({ 42 | filters => cleanup_user_model() 43 | }, { 44 | assert_debug_snapshot!(saved_user); 45 | }); 46 | 47 | let deliveries = ctx.mailer.unwrap().deliveries(); 48 | assert_eq!(deliveries.count, 1, "Exactly one email should be sent"); 49 | 50 | // with_settings!({ 51 | // filters => cleanup_email() 52 | // }, { 53 | // assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); 54 | // }); 55 | }) 56 | .await; 57 | } 58 | 59 | #[rstest] 60 | #[case("login_with_valid_password", "12341234")] 61 | #[case("login_with_invalid_password", "invalid-password")] 62 | #[tokio::test] 63 | #[serial] 64 | async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) { 65 | configure_insta!(); 66 | 67 | request::(|request, ctx| async move { 68 | let email = "test@loco.com"; 69 | let register_payload = serde_json::json!({ 70 | "name": "loco", 71 | "email": email, 72 | "password": "12341234" 73 | }); 74 | 75 | //Creating a new user 76 | let register_response = request 77 | .post("/api/auth/register") 78 | .json(®ister_payload) 79 | .await; 80 | 81 | assert_eq!( 82 | register_response.status_code(), 83 | 200, 84 | "Register request should succeed" 85 | ); 86 | 87 | let user = users::Model::find_by_email(&ctx.db, email).await.unwrap(); 88 | let email_verification_token = user 89 | .email_verification_token 90 | .expect("Email verification token should be generated"); 91 | request 92 | .get(&format!("/api/auth/verify/{email_verification_token}")) 93 | .await; 94 | 95 | //verify user request 96 | let response = request 97 | .post("/api/auth/login") 98 | .json(&serde_json::json!({ 99 | "email": email, 100 | "password": password 101 | })) 102 | .await; 103 | 104 | // Make sure email_verified_at is set 105 | let user = users::Model::find_by_email(&ctx.db, email) 106 | .await 107 | .expect("Failed to find user by email"); 108 | 109 | assert!( 110 | user.email_verified_at.is_some(), 111 | "Expected the email to be verified, but it was not. User: {:?}", 112 | user 113 | ); 114 | 115 | with_settings!({ 116 | filters => cleanup_user_model() 117 | }, { 118 | assert_debug_snapshot!(test_name, (response.status_code(), response.text())); 119 | }); 120 | }) 121 | .await; 122 | } 123 | 124 | #[tokio::test] 125 | #[serial] 126 | async fn can_login_without_verify() { 127 | configure_insta!(); 128 | 129 | request::(|request, _ctx| async move { 130 | let email = "test@loco.com"; 131 | let password = "12341234"; 132 | let register_payload = serde_json::json!({ 133 | "name": "loco", 134 | "email": email, 135 | "password": password 136 | }); 137 | 138 | //Creating a new user 139 | let register_response = request 140 | .post("/api/auth/register") 141 | .json(®ister_payload) 142 | .await; 143 | 144 | assert_eq!( 145 | register_response.status_code(), 146 | 200, 147 | "Register request should succeed" 148 | ); 149 | 150 | //verify user request 151 | let login_response = request 152 | .post("/api/auth/login") 153 | .json(&serde_json::json!({ 154 | "email": email, 155 | "password": password 156 | })) 157 | .await; 158 | 159 | assert_eq!( 160 | login_response.status_code(), 161 | 200, 162 | "Login request should succeed" 163 | ); 164 | 165 | with_settings!({ 166 | filters => cleanup_user_model() 167 | }, { 168 | assert_debug_snapshot!(login_response.text()); 169 | }); 170 | }) 171 | .await; 172 | } 173 | 174 | #[tokio::test] 175 | #[serial] 176 | async fn can_reset_password() { 177 | configure_insta!(); 178 | 179 | request::(|request, ctx| async move { 180 | let login_data = prepare_data::init_user_login(&request, &ctx).await; 181 | 182 | let forgot_payload = serde_json::json!({ 183 | "email": login_data.user.email, 184 | }); 185 | let forget_response = request.post("/api/auth/forgot").json(&forgot_payload).await; 186 | assert_eq!( 187 | forget_response.status_code(), 188 | 200, 189 | "Forget request should succeed" 190 | ); 191 | 192 | let user = users::Model::find_by_email(&ctx.db, &login_data.user.email) 193 | .await 194 | .expect("Failed to find user by email"); 195 | 196 | assert!( 197 | user.reset_token.is_some(), 198 | "Expected reset_token to be set, but it was None. User: {user:?}" 199 | ); 200 | assert!( 201 | user.reset_sent_at.is_some(), 202 | "Expected reset_sent_at to be set, but it was None. User: {user:?}" 203 | ); 204 | 205 | let new_password = "new-password"; 206 | let reset_payload = serde_json::json!({ 207 | "token": user.reset_token, 208 | "password": new_password, 209 | }); 210 | 211 | let reset_response = request.post("/api/auth/reset").json(&reset_payload).await; 212 | assert_eq!( 213 | reset_response.status_code(), 214 | 200, 215 | "Reset password request should succeed" 216 | ); 217 | 218 | let user = users::Model::find_by_email(&ctx.db, &user.email) 219 | .await 220 | .unwrap(); 221 | 222 | assert!(user.reset_token.is_none()); 223 | assert!(user.reset_sent_at.is_none()); 224 | 225 | assert_debug_snapshot!(reset_response.text()); 226 | 227 | let login_response = request 228 | .post("/api/auth/login") 229 | .json(&serde_json::json!({ 230 | "email": user.email, 231 | "password": new_password 232 | })) 233 | .await; 234 | 235 | assert_eq!( 236 | login_response.status_code(), 237 | 200, 238 | "Login request should succeed" 239 | ); 240 | 241 | let deliveries = ctx.mailer.unwrap().deliveries(); 242 | assert_eq!(deliveries.count, 2, "Exactly one email should be sent"); 243 | // with_settings!({ 244 | // filters => cleanup_email() 245 | // }, { 246 | // assert_debug_snapshot!(deliveries.messages); 247 | // }); 248 | }) 249 | .await; 250 | } 251 | 252 | #[tokio::test] 253 | #[serial] 254 | async fn can_get_current_user() { 255 | configure_insta!(); 256 | 257 | request::(|request, ctx| async move { 258 | let user = prepare_data::init_user_login(&request, &ctx).await; 259 | 260 | let (auth_key, auth_value) = prepare_data::auth_header(&user.token); 261 | let response = request 262 | .get("/api/auth/current") 263 | .add_header(auth_key, auth_value) 264 | .await; 265 | 266 | assert_eq!( 267 | response.status_code(), 268 | 200, 269 | "Current request should succeed" 270 | ); 271 | 272 | with_settings!({ 273 | filters => cleanup_user_model() 274 | }, { 275 | assert_debug_snapshot!((response.status_code(), response.text())); 276 | }); 277 | }) 278 | .await; 279 | } 280 | 281 | #[tokio::test] 282 | #[serial] 283 | async fn can_auth_with_magic_link() { 284 | configure_insta!(); 285 | request::(|request, ctx| async move { 286 | seed::(&ctx).await.unwrap(); 287 | 288 | let payload = serde_json::json!({ 289 | "email": "user1@example.com", 290 | }); 291 | let response = request.post("/api/auth/magic-link").json(&payload).await; 292 | assert_eq!( 293 | response.status_code(), 294 | 200, 295 | "Magic link request should succeed" 296 | ); 297 | 298 | let deliveries = ctx.mailer.unwrap().deliveries(); 299 | assert_eq!(deliveries.count, 1, "Exactly one email should be sent"); 300 | 301 | // let redact_token = format!("[a-zA-Z0-9]{{{}}}", users::MAGIC_LINK_LENGTH); 302 | // with_settings!({ 303 | // filters => { 304 | // let mut combined_filters = cleanup_email().clone(); 305 | // combined_filters.extend(vec![(r"(\\r\\n|=\\r\\n)", ""), (redact_token.as_str(), "[REDACT_TOKEN]") ]); 306 | // combined_filters 307 | // } 308 | // }, { 309 | // assert_debug_snapshot!(deliveries.messages); 310 | // }); 311 | 312 | let user = users::Model::find_by_email(&ctx.db, "user1@example.com") 313 | .await 314 | .expect("User should be found"); 315 | 316 | let magic_link_token = user 317 | .magic_link_token 318 | .expect("Magic link token should be generated"); 319 | let magic_link_response = request 320 | .get(&format!("/api/auth/magic-link/{magic_link_token}")) 321 | .await; 322 | assert_eq!( 323 | magic_link_response.status_code(), 324 | 200, 325 | "Magic link authentication should succeed" 326 | ); 327 | 328 | with_settings!({ 329 | filters => cleanup_user_model() 330 | }, { 331 | assert_debug_snapshot!(magic_link_response.text()); 332 | }); 333 | }) 334 | .await; 335 | } 336 | 337 | #[tokio::test] 338 | #[serial] 339 | async fn can_reject_invalid_email() { 340 | configure_insta!(); 341 | request::(|request, _ctx| async move { 342 | let invalid_email = "user1@temp-mail.com"; 343 | let payload = serde_json::json!({ 344 | "email": invalid_email, 345 | }); 346 | let response = request.post("/api/auth/magic-link").json(&payload).await; 347 | assert_eq!( 348 | response.status_code(), 349 | 400, 350 | "Expected request with invalid email '{invalid_email}' to be blocked, but it was allowed." 351 | ); 352 | }) 353 | .await; 354 | } 355 | 356 | #[tokio::test] 357 | #[serial] 358 | async fn can_reject_invalid_magic_link_token() { 359 | configure_insta!(); 360 | request::(|request, ctx| async move { 361 | seed::(&ctx).await.unwrap(); 362 | 363 | let magic_link_response = request.get("/api/auth/magic-link/invalid-token").await; 364 | assert_eq!( 365 | magic_link_response.status_code(), 366 | 401, 367 | "Magic link authentication should be rejected" 368 | ); 369 | }) 370 | .await; 371 | } 372 | -------------------------------------------------------------------------------- /example/src/models/users.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use chrono::{Duration, offset::Local}; 3 | use loco_rs::{auth::jwt, hash, prelude::*}; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::Map; 6 | use uuid::Uuid; 7 | 8 | pub use super::_entities::users::{self, ActiveModel, Entity, Model}; 9 | 10 | pub const MAGIC_LINK_LENGTH: i8 = 32; 11 | pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5; 12 | 13 | #[derive(Debug, Deserialize, Serialize)] 14 | pub struct LoginParams { 15 | pub email: String, 16 | pub password: String, 17 | } 18 | 19 | #[derive(Debug, Deserialize, Serialize)] 20 | pub struct RegisterParams { 21 | pub email: String, 22 | pub password: String, 23 | pub name: String, 24 | } 25 | 26 | #[derive(Debug, Validate, Deserialize)] 27 | pub struct Validator { 28 | #[validate(length(min = 2, message = "Name must be at least 2 characters long."))] 29 | pub name: String, 30 | #[validate(email(message = "invalid email"))] 31 | pub email: String, 32 | } 33 | 34 | impl Validatable for ActiveModel { 35 | fn validator(&self) -> Box { 36 | Box::new(Validator { 37 | name: self.name.as_ref().to_owned(), 38 | email: self.email.as_ref().to_owned(), 39 | }) 40 | } 41 | } 42 | 43 | #[async_trait::async_trait] 44 | impl ActiveModelBehavior for super::_entities::users::ActiveModel { 45 | async fn before_save(self, _db: &C, insert: bool) -> Result 46 | where 47 | C: ConnectionTrait, 48 | { 49 | self.validate()?; 50 | if insert { 51 | let mut this = self; 52 | this.pid = ActiveValue::Set(Uuid::new_v4()); 53 | this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4())); 54 | Ok(this) 55 | } else { 56 | Ok(self) 57 | } 58 | } 59 | } 60 | 61 | #[async_trait] 62 | impl Authenticable for Model { 63 | async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { 64 | let user = users::Entity::find() 65 | .filter( 66 | model::query::condition() 67 | .eq(users::Column::ApiKey, api_key) 68 | .build(), 69 | ) 70 | .one(db) 71 | .await?; 72 | user.ok_or_else(|| ModelError::EntityNotFound) 73 | } 74 | 75 | async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult { 76 | Self::find_by_pid(db, claims_key).await 77 | } 78 | } 79 | 80 | impl Model { 81 | /// finds a user by the provided email 82 | /// 83 | /// # Errors 84 | /// 85 | /// When could not find user by the given token or DB query error 86 | pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult { 87 | let user = users::Entity::find() 88 | .filter( 89 | model::query::condition() 90 | .eq(users::Column::Email, email) 91 | .build(), 92 | ) 93 | .one(db) 94 | .await?; 95 | user.ok_or_else(|| ModelError::EntityNotFound) 96 | } 97 | 98 | /// finds a user by the provided verification token 99 | /// 100 | /// # Errors 101 | /// 102 | /// When could not find user by the given token or DB query error 103 | pub async fn find_by_verification_token( 104 | db: &DatabaseConnection, 105 | token: &str, 106 | ) -> ModelResult { 107 | let user = users::Entity::find() 108 | .filter( 109 | model::query::condition() 110 | .eq(users::Column::EmailVerificationToken, token) 111 | .build(), 112 | ) 113 | .one(db) 114 | .await?; 115 | user.ok_or_else(|| ModelError::EntityNotFound) 116 | } 117 | 118 | /// finds a user by the magic token and verify and token expiration 119 | /// 120 | /// # Errors 121 | /// 122 | /// When could not find user by the given token or DB query error ot token expired 123 | pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult { 124 | let user = users::Entity::find() 125 | .filter( 126 | query::condition() 127 | .eq(users::Column::MagicLinkToken, token) 128 | .build(), 129 | ) 130 | .one(db) 131 | .await?; 132 | 133 | let user = user.ok_or_else(|| ModelError::EntityNotFound)?; 134 | if let Some(expired_at) = user.magic_link_expiration { 135 | if expired_at >= Local::now() { 136 | Ok(user) 137 | } else { 138 | tracing::debug!( 139 | user_pid = user.pid.to_string(), 140 | token_expiration = expired_at.to_string(), 141 | "magic token expired for the user." 142 | ); 143 | Err(ModelError::msg("magic token expired")) 144 | } 145 | } else { 146 | tracing::error!( 147 | user_pid = user.pid.to_string(), 148 | "magic link expiration time not exists" 149 | ); 150 | Err(ModelError::msg("expiration token not exists")) 151 | } 152 | } 153 | 154 | /// finds a user by the provided reset token 155 | /// 156 | /// # Errors 157 | /// 158 | /// When could not find user by the given token or DB query error 159 | pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult { 160 | let user = users::Entity::find() 161 | .filter( 162 | model::query::condition() 163 | .eq(users::Column::ResetToken, token) 164 | .build(), 165 | ) 166 | .one(db) 167 | .await?; 168 | user.ok_or_else(|| ModelError::EntityNotFound) 169 | } 170 | 171 | /// finds a user by the provided pid 172 | /// 173 | /// # Errors 174 | /// 175 | /// When could not find user or DB query error 176 | pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult { 177 | let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?; 178 | let user = users::Entity::find() 179 | .filter( 180 | model::query::condition() 181 | .eq(users::Column::Pid, parse_uuid) 182 | .build(), 183 | ) 184 | .one(db) 185 | .await?; 186 | user.ok_or_else(|| ModelError::EntityNotFound) 187 | } 188 | 189 | /// finds a user by the provided api key 190 | /// 191 | /// # Errors 192 | /// 193 | /// When could not find user by the given token or DB query error 194 | pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { 195 | let user = users::Entity::find() 196 | .filter( 197 | model::query::condition() 198 | .eq(users::Column::ApiKey, api_key) 199 | .build(), 200 | ) 201 | .one(db) 202 | .await?; 203 | user.ok_or_else(|| ModelError::EntityNotFound) 204 | } 205 | 206 | /// Verifies whether the provided plain password matches the hashed password 207 | /// 208 | /// # Errors 209 | /// 210 | /// when could not verify password 211 | #[must_use] 212 | pub fn verify_password(&self, password: &str) -> bool { 213 | hash::verify_password(password, &self.password) 214 | } 215 | 216 | /// Asynchronously creates a user with a password and saves it to the 217 | /// database. 218 | /// 219 | /// # Errors 220 | /// 221 | /// When could not save the user into the DB 222 | pub async fn create_with_password( 223 | db: &DatabaseConnection, 224 | params: &RegisterParams, 225 | ) -> ModelResult { 226 | let txn = db.begin().await?; 227 | 228 | if users::Entity::find() 229 | .filter( 230 | model::query::condition() 231 | .eq(users::Column::Email, ¶ms.email) 232 | .build(), 233 | ) 234 | .one(&txn) 235 | .await? 236 | .is_some() 237 | { 238 | return Err(ModelError::EntityAlreadyExists {}); 239 | } 240 | 241 | let password_hash = 242 | hash::hash_password(¶ms.password).map_err(|e| ModelError::Any(e.into()))?; 243 | let user = users::ActiveModel { 244 | email: ActiveValue::set(params.email.to_string()), 245 | password: ActiveValue::set(password_hash), 246 | name: ActiveValue::set(params.name.to_string()), 247 | ..Default::default() 248 | } 249 | .insert(&txn) 250 | .await?; 251 | 252 | txn.commit().await?; 253 | 254 | Ok(user) 255 | } 256 | 257 | /// Creates a JWT 258 | /// 259 | /// # Errors 260 | /// 261 | /// when could not convert user claims to jwt token 262 | pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult { 263 | Ok(jwt::JWT::new(secret).generate_token( 264 | expiration.to_owned(), 265 | self.pid.to_string(), 266 | Map::new(), 267 | )?) 268 | } 269 | } 270 | 271 | impl ActiveModel { 272 | /// Sets the email verification information for the user and 273 | /// updates it in the database. 274 | /// 275 | /// This method is used to record the timestamp when the email verification 276 | /// was sent and generate a unique verification token for the user. 277 | /// 278 | /// # Errors 279 | /// 280 | /// when has DB query error 281 | pub async fn set_email_verification_sent( 282 | mut self, 283 | db: &DatabaseConnection, 284 | ) -> ModelResult { 285 | self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into())); 286 | self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string())); 287 | Ok(self.update(db).await?) 288 | } 289 | 290 | /// Sets the information for a reset password request, 291 | /// generates a unique reset password token, and updates it in the 292 | /// database. 293 | /// 294 | /// This method records the timestamp when the reset password token is sent 295 | /// and generates a unique token for the user. 296 | /// 297 | /// # Arguments 298 | /// 299 | /// # Errors 300 | /// 301 | /// when has DB query error 302 | pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult { 303 | self.reset_sent_at = ActiveValue::set(Some(Local::now().into())); 304 | self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string())); 305 | Ok(self.update(db).await?) 306 | } 307 | 308 | /// Records the verification time when a user verifies their 309 | /// email and updates it in the database. 310 | /// 311 | /// This method sets the timestamp when the user successfully verifies their 312 | /// email. 313 | /// 314 | /// # Errors 315 | /// 316 | /// when has DB query error 317 | pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult { 318 | self.email_verified_at = ActiveValue::set(Some(Local::now().into())); 319 | Ok(self.update(db).await?) 320 | } 321 | 322 | /// Resets the current user password with a new password and 323 | /// updates it in the database. 324 | /// 325 | /// This method hashes the provided password and sets it as the new password 326 | /// for the user. 327 | /// 328 | /// # Errors 329 | /// 330 | /// when has DB query error or could not hashed the given password 331 | pub async fn reset_password( 332 | mut self, 333 | db: &DatabaseConnection, 334 | password: &str, 335 | ) -> ModelResult { 336 | self.password = 337 | ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?); 338 | self.reset_token = ActiveValue::Set(None); 339 | self.reset_sent_at = ActiveValue::Set(None); 340 | Ok(self.update(db).await?) 341 | } 342 | 343 | /// Creates a magic link token for passwordless authentication. 344 | /// 345 | /// Generates a random token with a specified length and sets an expiration time 346 | /// for the magic link. This method is used to initiate the magic link authentication flow. 347 | /// 348 | /// # Errors 349 | /// - Returns an error if database update fails 350 | pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { 351 | let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize); 352 | let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into()); 353 | 354 | self.magic_link_token = ActiveValue::set(Some(random_str)); 355 | self.magic_link_expiration = ActiveValue::set(Some(expired.into())); 356 | Ok(self.update(db).await?) 357 | } 358 | 359 | /// Verifies and invalidates the magic link after successful authentication. 360 | /// 361 | /// Clears the magic link token and expiration time after the user has 362 | /// successfully authenticated using the magic link. 363 | /// 364 | /// # Errors 365 | /// - Returns an error if database update fails 366 | pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { 367 | self.magic_link_token = ActiveValue::set(None); 368 | self.magic_link_expiration = ActiveValue::set(None); 369 | Ok(self.update(db).await?) 370 | } 371 | } 372 | --------------------------------------------------------------------------------