├── src ├── swagger │ ├── mod.rs │ └── generator.rs ├── views │ ├── mod.rs │ ├── macros.rs │ └── operates.rs ├── utils │ ├── mod.rs │ ├── server.rs │ ├── router.rs │ ├── prometheus_metrics.rs │ └── tls.rs ├── lib.rs ├── db.rs ├── error.rs └── test_helpers.rs ├── .gitignore ├── examples └── demo │ ├── README.md │ ├── src │ ├── entities │ │ ├── mod.rs │ │ ├── prelude.rs │ │ └── student.rs │ ├── main.rs │ └── check.rs │ ├── migration │ ├── src │ │ ├── main.rs │ │ ├── lib.rs │ │ └── m20220101_000001_create_table.rs │ ├── Cargo.toml │ └── README.md │ ├── Makefile │ ├── .env │ ├── compose.yaml │ └── Cargo.toml ├── statics └── swagger-ui-demo.png ├── clippy.toml ├── Makefile ├── LICENSE-MIT ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE └── README.md /src/swagger/mod.rs: -------------------------------------------------------------------------------- 1 | pub use generator::SwaggerGeneratorExt; 2 | 3 | mod generator; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | .idea/ 3 | target/ 4 | certs/ 5 | examples/demo/embed_files/ 6 | -------------------------------------------------------------------------------- /src/views/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod macros; 2 | pub mod operates; 3 | 4 | pub use operates::ModelViewExt; 5 | -------------------------------------------------------------------------------- /examples/demo/README.md: -------------------------------------------------------------------------------- 1 | A demo for the `axum-restful` includes `docker compose`, `postgres`,`sea-orm` 2 | -------------------------------------------------------------------------------- /statics/swagger-ui-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gongzhengyang/axum-restful/HEAD/statics/swagger-ui-demo.png -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | # which will confilict with `snafu::{OptionExt, ResultExt}`, cause many error 2 | disallowed-names = ["anyhow::Context"] 3 | -------------------------------------------------------------------------------- /examples/demo/src/entities/mod.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.0 2 | 3 | pub mod prelude; 4 | 5 | pub mod student; 6 | -------------------------------------------------------------------------------- /examples/demo/src/entities/prelude.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.0 2 | 3 | pub use super::student::Entity as Student; 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check 2 | check: 3 | cargo fmt 4 | cargo tomlfmt 5 | cargo clippy --all-targets 6 | cargo install --locked cargo-outdated 7 | cargo outdated -R 8 | cargo install cargo-udeps --locked 9 | cargo +nightly udeps 10 | -------------------------------------------------------------------------------- /examples/demo/migration/src/main.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[async_std::main] 4 | async fn main() { 5 | let db = axum_restful::get_db_connection_pool().await; 6 | migration::Migrator::refresh(db).await.unwrap(); 7 | } 8 | -------------------------------------------------------------------------------- /examples/demo/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check 2 | check: 3 | cargo fmt 4 | cargo tomlfmt 5 | cargo clippy --all-targets 6 | cargo install --locked cargo-outdated 7 | cargo outdated -R 8 | cargo install cargo-udeps --locked 9 | cargo +nightly udeps 10 | -------------------------------------------------------------------------------- /examples/demo/.env: -------------------------------------------------------------------------------- 1 | # config the base pg connect params 2 | POSTGRES_DB=demo 3 | POSTGRES_USER=demo-user 4 | POSTGRES_PASSWORD=demo-password 5 | 6 | # used by axum-restful framework to specific a database connection 7 | DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB} 8 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod prometheus_metrics; 2 | pub mod router; 3 | pub mod server; 4 | pub mod tls; 5 | 6 | pub use prometheus_metrics::{track_metrics, PrometheusMetrics}; 7 | pub use router::handle_not_found; 8 | pub use server::shutdown_signal; 9 | pub use tls::{redirect_http_to_https, GenerateCertKey}; 10 | -------------------------------------------------------------------------------- /examples/demo/migration/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use sea_orm_migration::prelude::*; 2 | 3 | mod m20220101_000001_create_table; 4 | 5 | pub struct Migrator; 6 | 7 | #[async_trait::async_trait] 8 | impl MigratorTrait for Migrator { 9 | fn migrations() -> Vec> { 10 | vec![Box::new(m20220101_000001_create_table::Migration)] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/demo/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:16-bookworm 4 | container_name: demo-postgres 5 | restart: always 6 | volumes: 7 | - demo-postgres:/var/lib/postgresql/data 8 | ports: 9 | - "127.0.0.1:5432:5432" 10 | environment: 11 | - POSTGRES_DB=${POSTGRES_DB} 12 | - POSTGRES_USER=${POSTGRES_USER} 13 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 14 | 15 | volumes: 16 | demo-postgres: {} 17 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(nightly_error_messages, feature(rustc_attrs))] 2 | //! axum A restful framework based on `axum` and `sea-orm`. Inspired by `django-rest-framework`. 3 | //! The goal of the project is to build an enterprise-level production framework. 4 | pub mod db; 5 | pub mod error; 6 | pub mod swagger; 7 | pub mod test_helpers; 8 | pub mod utils; 9 | pub mod views; 10 | 11 | pub use db::get_db_connection_pool; 12 | pub use error::AppError; 13 | pub use views::ModelViewExt; 14 | -------------------------------------------------------------------------------- /examples/demo/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 | axum-restful = { path = "../../../" } 14 | 15 | [dependencies.sea-orm-migration] 16 | version = "0.12" 17 | features = [ 18 | # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. 19 | # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. 20 | # e.g. 21 | "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature 22 | "sqlx-postgres", # `DATABASE_DRIVER` feature 23 | ] 24 | -------------------------------------------------------------------------------- /examples/demo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "demo" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | [workspace] 8 | members = [".", "migration"] 9 | 10 | [dependencies] 11 | aide = "0.13" 12 | axum = "0.7" 13 | axum-restful = { path = "../../" } 14 | chrono = "0.4" 15 | migration = { path = "./migration" } 16 | once_cell = "1" 17 | schemars = { version = "0.8", features = ["chrono"] } 18 | sea-orm = { version = "0.12", features = ["macros", "sqlx-postgres", "runtime-tokio-rustls"] } 19 | sea-orm-migration = { version = "0.12", features = ["sqlx-postgres", "runtime-tokio-rustls",] } 20 | serde = { version = "1.0", features = ["derive"] } 21 | serde_json = "1.0" 22 | tokio = { version = "1", features = ["full"] } 23 | tracing = "0.1" 24 | tracing-subscriber = "0.3" 25 | -------------------------------------------------------------------------------- /examples/demo/src/entities/student.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.0 2 | 3 | use schemars::JsonSchema; 4 | use sea_orm::entity::prelude::*; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Clone, Debug, PartialEq, JsonSchema, DeriveEntityModel, Serialize, Deserialize)] 8 | #[sea_orm(table_name = "student")] 9 | pub struct Model { 10 | #[sea_orm(primary_key)] 11 | pub id: i64, 12 | /// student name 13 | pub name: String, 14 | /// student comen from 15 | pub region: String, 16 | pub age: i16, 17 | /// this record create time 18 | pub create_time: DateTime, 19 | #[sea_orm(column_type = "Double")] 20 | pub score: f64, 21 | /// man is true, false is women 22 | pub gender: bool, 23 | } 24 | 25 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 26 | pub enum Relation {} 27 | 28 | impl ActiveModelBehavior for ActiveModel {} 29 | -------------------------------------------------------------------------------- /examples/demo/migration/README.md: -------------------------------------------------------------------------------- 1 | # Running Migrator CLI 2 | 3 | - Generate a new migration file 4 | ```sh 5 | cargo run -- migrate generate MIGRATION_NAME 6 | ``` 7 | - Apply all pending migrations 8 | ```sh 9 | cargo run 10 | ``` 11 | ```sh 12 | cargo run -- up 13 | ``` 14 | - Apply first 10 pending migrations 15 | ```sh 16 | cargo run -- up -n 10 17 | ``` 18 | - Rollback last applied migrations 19 | ```sh 20 | cargo run -- down 21 | ``` 22 | - Rollback last 10 applied migrations 23 | ```sh 24 | cargo run -- down -n 10 25 | ``` 26 | - Drop all tables from the database, then reapply all migrations 27 | ```sh 28 | cargo run -- fresh 29 | ``` 30 | - Rollback all applied migrations, then reapply all migrations 31 | ```sh 32 | cargo run -- refresh 33 | ``` 34 | - Rollback all applied migrations 35 | ```sh 36 | cargo run -- reset 37 | ``` 38 | - Check the status of all migrations 39 | ```sh 40 | cargo run -- status 41 | ``` 42 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Gongzhengyang 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /src/utils/server.rs: -------------------------------------------------------------------------------- 1 | use tokio::signal; 2 | 3 | /// copy from axum/examples/graceful-shutdown/src/main.rs 4 | /// receive Ctrl + C and graceful shutdown server 5 | /// # graceful shutdown Example 6 | /// ```rust,no_run 7 | /// use axum::{Router, routing::get}; 8 | /// use axum_restful::utils::shutdown_signal; 9 | /// 10 | /// // let app = Router::new().route("/", get(|| async {"hello"})); 11 | /// # async { 12 | /// //axum::Server::bind(&"".parse().unwrap()) 13 | /// // .serve(app.into_make_service()) 14 | /// // .with_graceful_shutdown(shutdown_signal()) 15 | /// // .await 16 | /// // .unwrap() 17 | /// # }; 18 | /// ``` 19 | pub async fn shutdown_signal() { 20 | let ctrl_c = async { 21 | signal::ctrl_c() 22 | .await 23 | .expect("failed to install Ctrl+C handler"); 24 | }; 25 | 26 | #[cfg(unix)] 27 | let terminate = async { 28 | signal::unix::signal(signal::unix::SignalKind::terminate()) 29 | .expect("failed to install signal handler") 30 | .recv() 31 | .await; 32 | }; 33 | 34 | #[cfg(not(unix))] 35 | let terminate = std::future::pending::<()>(); 36 | 37 | tokio::select! { 38 | _ = ctrl_c => {}, 39 | _ = terminate => {}, 40 | } 41 | 42 | println!("signal received, starting graceful shutdown"); 43 | } 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | # Unreleased 9 | - None. 10 | 11 | 12 | 13 | ## 0.5.0(2023-12-19) 14 | 15 | - **feat:** support `axum 0.7` 16 | 17 | ## 0.4.0(2023-09-26) 18 | - feat: update swagger `png` 19 | 20 | - feat: replace `EmptyBodyRespons`e into `()`, update `readme.md` for usage 21 | - feat: update swagger files and remove patch method 22 | - feat: add check for operate `http` 23 | - `refactor`: move `http` actions into `HTTPOperateCheck` 24 | - feat: add more log for `http` operate, add more test check for `http` actor 25 | - feat: add more fields for test 26 | - feat: add snafu for better err display 27 | 28 | ## 0.3.0(2023-04-03) 29 | 30 | - **added:** add initial support for swagger based on [aide](https://github.com/tamasfe/aide) project, swagger support still needs a lot of improvement 31 | 32 | 33 | ## 0.2.0(2023-04-01) 34 | 35 | - **added:** Add views with create, update, query methods 36 | - **added:** Add default 404 handler, graceful shutdown, `tls` support in `utils` 37 | - **added:** Add crate `AppError impl` anyhow::Error 38 | - **added:** Add `prometheus` metrics server 39 | -------------------------------------------------------------------------------- /src/utils/router.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use axum::response::IntoResponse; 3 | 4 | /// Handle for not found and return 404 5 | /// # Global handle not found Example 6 | /// 7 | /// ```rust,no_run 8 | /// use axum::{Router, routing::get, ServiceExt}; 9 | /// use axum_restful::utils::handle_not_found; 10 | /// 11 | /// let app = Router::new() 12 | /// .route("/", get(|| async { "Hello world!"})) 13 | /// .fallback(handle_not_found); 14 | /// # async { 15 | /// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); 16 | /// axum::serve(listener, app.into_make_service()).await.unwrap(); 17 | /// # }; 18 | /// ``` 19 | pub async fn handle_not_found() -> impl IntoResponse { 20 | (StatusCode::NOT_FOUND, "nothing to see here") 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use super::*; 26 | use crate::test_helpers::*; 27 | use axum::{routing::get, Router}; 28 | 29 | #[tokio::test] 30 | async fn handle_404() { 31 | let app = Router::new() 32 | .route("/", get(|| async { "Hello" })) 33 | .fallback(handle_not_found); 34 | let client = TestClient::new(app); 35 | let res = client.get("/").send().await; 36 | assert_eq!(res.status(), StatusCode::OK); 37 | assert_eq!(res.text().await, "Hello".to_owned()); 38 | 39 | let res = client.get("/test").send().await; 40 | assert_eq!(res.status(), StatusCode::NOT_FOUND); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-restful" 3 | version = "0.5.0" 4 | edition = "2021" 5 | categories = ["web-programming::http-server", "asynchronous"] 6 | description = "A restful framework based on axum and sea-orm." 7 | keywords = ["axum", "sea-orm", "restful"] 8 | authors = ["Gongzhengyang "] 9 | license = "MIT OR Apache-2.0" 10 | readme = "README.md" 11 | repository = "https://github.com/gongzhengyang/axum-restful" 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [dependencies] 15 | awesome-operates = "0.1" 16 | aide = { version = "0.13", features = ["redoc", "macros", "axum-extra-query", "axum"] } 17 | anyhow = "1.0" 18 | async-trait = "0.1" 19 | axum = "0.7.1" 20 | axum-core = "0.4" 21 | axum-server = { version = "0.5", features = ["tls-rustls"] } 22 | bytes = "1" 23 | http = "1.0" 24 | hyper = "1.0.1" 25 | log = "0.4" 26 | metrics = "0.21" 27 | metrics-exporter-prometheus = "0.12" 28 | mime_guess = "2" 29 | paste = "1" 30 | rcgen = "0.12" 31 | reqwest = { version = "0.11.14", default-features = false, features = ["json", "stream", "multipart"] } 32 | rust-embed = { version = "8", features = ["compression", "debug-embed"] } 33 | schemars = "0.8" 34 | sea-orm = { version = "0.12", features = ["macros", "sqlx-postgres", "runtime-tokio-rustls", "tests-cfg", "mock"] } 35 | serde = { version = "1.0", features = ["derive"] } 36 | serde_json = "1.0" 37 | snafu = { version = "0.7", features = ["backtraces"] } 38 | tokio = { version = "1", features = ["full"] } 39 | tokio-rustls = "0.25" 40 | tower = "0.4" 41 | tower-http = {version = "0.5", features = ["full"]} 42 | tower-service = "0.3.2" 43 | tracing = "0.1" 44 | -------------------------------------------------------------------------------- /examples/demo/src/main.rs: -------------------------------------------------------------------------------- 1 | use schemars::JsonSchema; 2 | use sea_orm_migration::prelude::MigratorTrait; 3 | use tokio::net::TcpListener; 4 | 5 | use axum_restful::swagger::SwaggerGeneratorExt; 6 | use axum_restful::views::ModelViewExt; 7 | 8 | use crate::entities::student; 9 | 10 | mod check; 11 | mod entities; 12 | 13 | #[tokio::main] 14 | async fn main() { 15 | tracing_subscriber::fmt::init(); 16 | let db = axum_restful::get_db_connection_pool().await; 17 | let _ = migration::Migrator::down(db, None).await; 18 | migration::Migrator::up(db, None).await.unwrap(); 19 | tracing::info!("migrate success"); 20 | 21 | aide::gen::on_error(|error| { 22 | tracing::error!("swagger api gen error: {error}"); 23 | }); 24 | aide::gen::extract_schemas(true); 25 | 26 | /// student 27 | #[derive(JsonSchema)] 28 | struct StudentView; 29 | 30 | impl ModelViewExt for StudentView { 31 | fn order_by_desc() -> student::Column { 32 | student::Column::Id 33 | } 34 | } 35 | 36 | let path = "/api/student"; 37 | let app = StudentView::http_router(path); 38 | check::check_curd_operate_correct(app.clone(), path, db).await; 39 | 40 | // if you want to generate swagger docs 41 | // impl OperationInput and SwaggerGenerator and change app into http_routers_with_swagger 42 | impl aide::operation::OperationInput for student::Model {} 43 | impl axum_restful::swagger::SwaggerGeneratorExt for StudentView {} 44 | let app = StudentView::http_router_with_swagger(path, StudentView::model_api_router()).await.unwrap(); 45 | 46 | let addr = "0.0.0.0:3000"; 47 | tracing::info!("listen at {addr}"); 48 | tracing::info!("visit http://127.0.0.1:3000/docs/swagger/ for swagger api"); 49 | let listener = TcpListener::bind(addr).await.unwrap(); 50 | axum::serve(listener, app.into_make_service()).await.unwrap(); 51 | } 52 | -------------------------------------------------------------------------------- /src/views/macros.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! inner_generate_by_params { 3 | ($key:ident, $key_display:expr, $default:expr) => { 4 | paste::paste! { 5 | fn [](query: &Value) -> anyhow::Result { 6 | let value = query.get(Self::[]()); 7 | Ok(value.context($crate::error::OptionValueNoneSnafu)?.as_str().unwrap_or("").parse::()?) 8 | } 9 | 10 | #[inline] 11 | fn []() -> &'static str { 12 | concat!("page_", $key_display) 13 | } 14 | 15 | #[inline] 16 | fn []() -> u64 { 17 | $default 18 | } 19 | } 20 | }; 21 | } 22 | 23 | #[macro_export] 24 | macro_rules! generate_by_params { 25 | ($key:ident, $key_display:expr, $default:expr) => { 26 | paste::paste! { 27 | fn [](query: &Value) -> u64 { 28 | let value = Self::[](query).unwrap_or(Self::[]()); 29 | tracing::debug!("params for {} is {}", Self::[](), value); 30 | value 31 | } 32 | } 33 | $crate::inner_generate_by_params!($key, $key_display, $default); 34 | }; 35 | ($key:ident, $key_display:expr, $default:expr, $reduce:expr) => { 36 | paste::paste! { 37 | fn [](query: &Value) -> u64 { 38 | let mut value = Self::[](query).unwrap_or(Self::[]()); 39 | if value >= $reduce { 40 | value -= $reduce; 41 | } 42 | tracing::debug!("params for {} is {}", Self::[](), value); 43 | value 44 | } 45 | } 46 | $crate::inner_generate_by_params!($key, $key_display, $default); 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /examples/demo/migration/src/m20220101_000001_create_table.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | // Replace the sample below with your own migration scripts 10 | manager 11 | .create_table( 12 | Table::create() 13 | .table(Student::Table) 14 | .if_not_exists() 15 | .col( 16 | ColumnDef::new(Student::Id) 17 | .big_integer() 18 | .not_null() 19 | .auto_increment() 20 | .primary_key(), 21 | ) 22 | .col(ColumnDef::new(Student::Name).string().not_null()) 23 | .col(ColumnDef::new(Student::Region).string().not_null()) 24 | .col(ColumnDef::new(Student::Age).small_integer().not_null()) 25 | .col(ColumnDef::new(Student::CreateTime).date_time().not_null()) 26 | .col(ColumnDef::new(Student::Score).double().not_null()) 27 | .col( 28 | ColumnDef::new(Student::Gender) 29 | .boolean() 30 | .not_null() 31 | .default(Expr::value(true)), 32 | ) 33 | .to_owned(), 34 | ) 35 | .await 36 | } 37 | 38 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 39 | // Replace the sample below with your own migration scripts 40 | 41 | manager 42 | .drop_table(Table::drop().table(Student::Table).to_owned()) 43 | .await 44 | } 45 | } 46 | 47 | /// Learn more at https://docs.rs/sea-query#iden 48 | #[derive(Iden)] 49 | enum Student { 50 | Table, 51 | Id, 52 | Name, 53 | Region, 54 | Age, 55 | CreateTime, 56 | Score, 57 | Gender, 58 | } 59 | -------------------------------------------------------------------------------- /src/db.rs: -------------------------------------------------------------------------------- 1 | use http::Uri; 2 | use std::time::Duration; 3 | 4 | use sea_orm::{ConnectOptions, Database, DatabaseConnection}; 5 | use tokio::sync::OnceCell; 6 | 7 | static DB_CONNECTION: OnceCell = OnceCell::const_new(); 8 | 9 | /// only init once 10 | /// will read some env keys if exists. 11 | /// 12 | /// env **`DATABASE_URL`** to specific the database connect options, default is **`postgres://demo-user:demo-password@localhost:5432/demo`** 13 | /// 14 | /// env **`SQLX_LOGGING`** to config the sqlx logging, default is **`false`**, can chaneg into **`true`** 15 | /// 16 | /// env **`SQLX_LOGGING_LEVEL`** to config the sqlx logging level, default is `info`, choices is `debug`, `info`, `warning`..., same as `log::Level`. 17 | pub async fn get_db_connection_pool() -> &'static DatabaseConnection { 18 | DB_CONNECTION 19 | .get_or_init(|| async { 20 | let db_uri = std::env::var("DATABASE_URL") 21 | .unwrap_or("postgres://demo-user:demo-password@localhost:5432/demo".to_owned()); 22 | let parsed_uri = db_uri.parse::().unwrap(); 23 | tracing::info!( 24 | "intial {} connection at {}:{} database {}", 25 | parsed_uri.scheme_str().unwrap(), 26 | parsed_uri.host().unwrap(), 27 | parsed_uri.port().unwrap(), 28 | parsed_uri.path() 29 | ); 30 | let sqlx_logging = std::env::var("SQLX_LOGGING") 31 | .unwrap_or("false".to_owned()) 32 | .parse::() 33 | .unwrap_or(false); 34 | let sqlx_logging_level = std::env::var("SQLX_LOGGING_LEVEL") 35 | .unwrap_or("info".to_owned()) 36 | .parse::() 37 | .unwrap(); 38 | let opt = ConnectOptions::new(db_uri) 39 | .max_connections(100) 40 | .min_connections(5) 41 | .connect_timeout(Duration::from_secs(5)) 42 | .acquire_timeout(Duration::from_secs(5)) 43 | .idle_timeout(Duration::from_secs(100)) 44 | .max_lifetime(Duration::from_secs(100)) 45 | .sqlx_logging(sqlx_logging) 46 | .set_schema_search_path("public".to_owned()) 47 | .sqlx_logging_level(sqlx_logging_level) 48 | .to_owned(); // Setting default PostgreSQL schema 49 | Database::connect(opt) 50 | .await 51 | .expect("connect database error") 52 | }) 53 | .await 54 | } 55 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use aide::gen::GenContext; 2 | use aide::openapi::Operation; 3 | use std::fmt::Debug; 4 | 5 | use aide::OperationOutput; 6 | use axum::{ 7 | http::StatusCode, 8 | response::{IntoResponse, Response}, 9 | Json, 10 | }; 11 | use schemars::JsonSchema; 12 | use sea_orm::DbErr; 13 | use serde::Serialize; 14 | use snafu::{Location, Snafu}; 15 | 16 | #[derive(Debug, Snafu)] 17 | #[snafu(visibility(pub))] 18 | pub enum AppError { 19 | #[snafu(display("internal server error"))] 20 | InternalServer { location: Location }, 21 | 22 | #[snafu(display("create instance error"))] 23 | CreateInstance { source: DbErr, location: Location }, 24 | 25 | #[snafu(display("instance not found with primary key: {}", pk))] 26 | PrimaryKeyNotFound { pk: u64, location: Location }, 27 | 28 | #[snafu(display("query database failed: {}", source))] 29 | OperateDatabase { source: DbErr, location: Location }, 30 | 31 | #[snafu(display("option value is none"))] 32 | OptionValueNone { location: Location }, 33 | 34 | #[snafu(display("unkonwn error"))] 35 | Unknown, 36 | } 37 | 38 | #[derive(Debug, JsonSchema, Serialize)] 39 | pub struct ErrorMessage { 40 | pub message: String, 41 | } 42 | 43 | impl IntoResponse for AppError { 44 | fn into_response(self) -> Response { 45 | let status_code = match self { 46 | AppError::PrimaryKeyNotFound { .. } => StatusCode::NOT_FOUND, 47 | _ => StatusCode::INTERNAL_SERVER_ERROR, 48 | }; 49 | tracing::error!("error happened: {self:?}"); 50 | ( 51 | status_code, 52 | Json(ErrorMessage { 53 | message: format!("{}", self), 54 | }), 55 | ) 56 | .into_response() 57 | } 58 | } 59 | 60 | impl OperationOutput for AppError { 61 | type Inner = Self; 62 | 63 | fn operation_response( 64 | ctx: &mut GenContext, 65 | operation: &mut Operation, 66 | ) -> Option { 67 | as OperationOutput>::operation_response(ctx, operation) 68 | } 69 | 70 | fn inferred_responses( 71 | ctx: &mut GenContext, 72 | operation: &mut Operation, 73 | ) -> Vec<(Option, aide::openapi::Response)> { 74 | let mut resp = vec![]; 75 | if let Some(response) = <() as OperationOutput>::operation_response(ctx, operation) { 76 | resp.push((Some(204), response)); 77 | } 78 | if let Some(response) = ::operation_response(ctx, operation) { 79 | resp.push((Some(500), response)); 80 | } 81 | resp 82 | } 83 | } 84 | 85 | pub type Result = std::result::Result; 86 | -------------------------------------------------------------------------------- /src/utils/prometheus_metrics.rs: -------------------------------------------------------------------------------- 1 | use std::{future::ready, time::Instant}; 2 | 3 | use async_trait::async_trait; 4 | use axum::{ 5 | extract::MatchedPath, extract::Request, middleware::Next, response::Response, routing::get, 6 | Router, 7 | }; 8 | use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle}; 9 | use tokio::net::TcpListener; 10 | 11 | /// based on axum/examples/prometheus-metrics/src/main.rs 12 | /// ```rust,no_run 13 | /// use axum::{Router, ServiceExt, routing::get, middleware}; 14 | /// use axum_restful::utils::{PrometheusMetrics, track_metrics}; 15 | /// 16 | /// struct Metrics; 17 | /// impl PrometheusMetrics for Metrics {} 18 | /// tokio::spawn(async { 19 | /// Metrics::start_metrics_server().await; 20 | /// }); 21 | /// 22 | /// let app = Router::new() 23 | /// .route("/hello", get(|| async {"hello"})) 24 | /// .route_layer(middleware::from_fn(track_metrics)); 25 | /// # async { 26 | /// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); 27 | /// axum::serve(listener, app).await.unwrap(); 28 | /// # }; 29 | /// ``` 30 | /// and then you can visit http://127.0.0.1:3000/hello to get response from axum server, 31 | /// next the visis metrics is recorded in prometheus metrics, 32 | /// the default prometheus metrics is http://0.0.0.0:3001/metrics. 33 | /// ip and port can modified by [`PrometheusMetrics::get_metrics_addr`] 34 | /// url path can modified by [`PrometheusMetrics::get_metrics_path`] 35 | #[async_trait] 36 | pub trait PrometheusMetrics { 37 | fn get_exponential_seconds() -> Vec { 38 | vec![ 39 | 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 40 | ] 41 | } 42 | 43 | fn get_prometheus_handle() -> PrometheusHandle { 44 | PrometheusBuilder::new() 45 | .set_buckets_for_metric( 46 | Matcher::Full("http_requests_duration_seconds".to_string()), 47 | &Self::get_exponential_seconds(), 48 | ) 49 | .unwrap() 50 | .install_recorder() 51 | .unwrap() 52 | } 53 | 54 | fn get_prometheus_app() -> Router { 55 | let recorder_handle = Self::get_prometheus_handle(); 56 | Router::new().route( 57 | Self::get_metrics_path(), 58 | get(move || ready(recorder_handle.render())), 59 | ) 60 | } 61 | 62 | fn get_metrics_path() -> &'static str { 63 | "/metrics" 64 | } 65 | 66 | fn get_metrics_addr() -> String { 67 | "0.0.0.0:3001".to_owned() 68 | } 69 | 70 | async fn start_metrics_server() { 71 | let addr = Self::get_metrics_addr(); 72 | tracing::debug!("listening on {:?}", addr); 73 | let listener = TcpListener::bind(addr).await.unwrap(); 74 | axum::serve(listener, Self::get_prometheus_app().into_make_service()) 75 | .await 76 | .unwrap(); 77 | } 78 | } 79 | 80 | /// a middle record the request info by added into axum middlewares 81 | pub async fn track_metrics(req: Request, next: Next) -> Response { 82 | let start = Instant::now(); 83 | let path = if let Some(matched_path) = req.extensions().get::() { 84 | matched_path.as_str().to_owned() 85 | } else { 86 | req.uri().path().to_owned() 87 | }; 88 | let method = req.method().clone(); 89 | let response = next.run(req).await; 90 | let latency = start.elapsed().as_secs_f64(); 91 | let status = response.status().as_u16().to_string(); 92 | let labels = [ 93 | ("method", method.to_string()), 94 | ("path", path), 95 | ("status", status), 96 | ]; 97 | metrics::increment_counter!("http_requests_total", &labels); 98 | metrics::histogram!("http_requests_duration_seconds", latency, &labels); 99 | response 100 | } 101 | -------------------------------------------------------------------------------- /src/utils/tls.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf}; 2 | 3 | use async_trait::async_trait; 4 | use axum::{ 5 | extract::Host, 6 | handler::HandlerWithoutStateExt, 7 | http::{StatusCode, Uri}, 8 | response::Redirect, 9 | }; 10 | use axum_server::tls_rustls::RustlsConfig; 11 | use rcgen::{date_time_ymd, Certificate, CertificateParams, DistinguishedName, DnType, SanType}; 12 | 13 | /// enable https 14 | /// you can config the cert, private key filepath 15 | /// config generate if target filepath is not exists 16 | /// ```rust,no_run 17 | /// use std::net::SocketAddr; 18 | /// use axum::{Router, routing::get}; 19 | /// use axum_restful::utils::{GenerateCertKey, redirect_http_to_https}; 20 | /// 21 | /// struct GenerateAppCertKey; 22 | /// impl GenerateCertKey for GenerateAppCertKey {} 23 | /// 24 | /// // config http,https ports 25 | /// let http_port = 3000; 26 | /// let https_port = 3001; 27 | /// let ip = "0.0.0.0"; 28 | /// 29 | /// // spawn a http service to redirect request to https service 30 | /// # tokio::spawn(async move { 31 | /// redirect_http_to_https(http_port, https_port, ip).await; 32 | /// # }); 33 | /// 34 | /// let app: Router = Router::new().route("/hello", get(|| async { "Hello, world!" })); 35 | /// # async { 36 | /// let tls_config = GenerateAppCertKey::get_rustls_config(true).await.unwrap(); 37 | /// let addr: SocketAddr = format!("{}:{}", ip, https_port).as_str().parse().unwrap(); 38 | /// //axum_server::bind_rustls(addr, tls_config) 39 | /// // .serve(app.into_make_service()) 40 | /// // .await 41 | /// // .unwrap(); 42 | /// // let addr = format!("{}:{}", ip, https_port); 43 | /// // let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); 44 | /// # }; 45 | /// ``` 46 | #[async_trait] 47 | pub trait GenerateCertKey { 48 | fn get_cert_key_path() -> anyhow::Result<(String, String)> { 49 | fs::create_dir_all("certs/")?; 50 | Ok(("certs/cert.pem".to_owned(), "certs/key.pem".to_owned())) 51 | } 52 | 53 | async fn get_rustls_config(create_if_not_exists: bool) -> anyhow::Result { 54 | let (cert, key) = Self::get_cert_key_path()?; 55 | let cert_pathbuf = PathBuf::from(cert); 56 | let key_pathbuf = PathBuf::from(key); 57 | if create_if_not_exists && (!cert_pathbuf.exists() | !key_pathbuf.exists()) { 58 | tracing::info!( 59 | "generate cert at {} and key at {}", 60 | cert_pathbuf.to_str().unwrap(), 61 | key_pathbuf.to_str().unwrap() 62 | ); 63 | Self::generate_cert_key()?; 64 | } 65 | Ok(RustlsConfig::from_pem_file(cert_pathbuf, key_pathbuf) 66 | .await 67 | .unwrap()) 68 | } 69 | 70 | fn generate_cert_key() -> anyhow::Result<()> { 71 | let cert = Certificate::from_params(Self::get_cert_params())?; 72 | let pem_serialized = cert.serialize_pem()?; 73 | println!("{}", pem_serialized); 74 | println!("{}", cert.serialize_private_key_pem()); 75 | let (cert_path, key_path) = Self::get_cert_key_path()?; 76 | fs::write(cert_path, pem_serialized.as_bytes())?; 77 | fs::write(key_path, cert.serialize_private_key_pem().as_bytes())?; 78 | Ok(()) 79 | } 80 | 81 | fn get_cert_params() -> CertificateParams { 82 | let mut params: CertificateParams = Default::default(); 83 | params.not_before = date_time_ymd(1975, 1, 1); 84 | params.not_after = date_time_ymd(4096, 1, 1); 85 | params.distinguished_name = DistinguishedName::new(); 86 | params 87 | .distinguished_name 88 | .push(DnType::OrganizationName, "Axum-restful"); 89 | params 90 | .distinguished_name 91 | .push(DnType::CommonName, "Axum-restful common name"); 92 | params.subject_alt_names = vec![SanType::DnsName("localhost".to_string())]; 93 | params 94 | } 95 | } 96 | 97 | pub async fn redirect_http_to_https(http_port: u16, https_port: u16, http_ip: &str) { 98 | fn make_https(host: String, uri: Uri, http_port: u16, https_port: u16) -> anyhow::Result { 99 | let mut parts = uri.into_parts(); 100 | 101 | parts.scheme = Some(axum::http::uri::Scheme::HTTPS); 102 | 103 | if parts.path_and_query.is_none() { 104 | parts.path_and_query = Some("/".parse().unwrap()); 105 | } 106 | 107 | let https_host = host.replace(&http_port.to_string(), &https_port.to_string()); 108 | parts.authority = Some(https_host.parse()?); 109 | 110 | Ok(Uri::from_parts(parts)?) 111 | } 112 | 113 | let redirect = move |Host(host): Host, uri: Uri| async move { 114 | match make_https(host, uri, http_port, https_port) { 115 | Ok(uri) => Ok(Redirect::permanent(&uri.to_string())), 116 | Err(error) => { 117 | tracing::warn!(%error, "failed to convert URI to HTTPS"); 118 | Err(StatusCode::BAD_REQUEST) 119 | } 120 | } 121 | }; 122 | 123 | let addr = format!("{}:{}", http_ip, http_port); 124 | tracing::debug!("http redirect listening on {}", addr); 125 | let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); 126 | axum::serve(listener, redirect.into_make_service()) 127 | .await 128 | .unwrap(); 129 | } 130 | -------------------------------------------------------------------------------- /src/swagger/generator.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use aide::{ 4 | axum::{ 5 | routing::{get, get_with}, 6 | ApiRouter, 7 | }, 8 | openapi::OpenApi, 9 | transform::{TransformOpenApi, TransformOperation}, 10 | }; 11 | use async_trait::async_trait; 12 | use axum::{ 13 | response::{IntoResponse, Response}, 14 | Extension, Json, Router, 15 | }; 16 | use schemars::{gen, JsonSchema}; 17 | use sea_orm::{ActiveModelBehavior, ActiveModelTrait, EntityTrait, IntoActiveModel}; 18 | use serde::Serialize; 19 | use tower_http::services::ServeDir; 20 | 21 | use crate::views::ModelViewExt; 22 | 23 | /// generate swagger docs for service 24 | /// when the service is up 25 | /// you can visit below 26 | /// ```http 27 | /// // swagger doc 28 | /// http://{ipaddress}:{port}/docs/swagger/ 29 | /// // openapi doc 30 | /// http://{ipaddress}:{port}/docs/openapi 31 | /// ``` 32 | #[async_trait] 33 | pub trait SwaggerGeneratorExt: 'static + ModelViewExt 34 | where 35 | Self: Send + JsonSchema, 36 | T: ActiveModelTrait + ActiveModelBehavior + Send + 'static + Sync, 37 | ::Model: IntoActiveModel + Serialize + Sync + JsonSchema, 38 | for<'de> ::Model: serde::de::Deserialize<'de>, 39 | { 40 | fn modle_schema_description() -> String { 41 | let mut gen = gen::SchemaGenerator::default(); 42 | let value = serde_json::json!(Self::json_schema(&mut gen)); 43 | if let Some(description) = value["description"].as_str() { 44 | description.to_owned() 45 | } else { 46 | Self::modle_name() 47 | } 48 | } 49 | 50 | #[inline] 51 | fn serve_dir_path() -> &'static str { 52 | awesome_operates::embed::EXTRACT_SWAGGER_DIR_PATH 53 | } 54 | 55 | #[inline] 56 | fn redoc_openapi_json_url() -> &'static str { 57 | "/docs/openapi/api.json" 58 | } 59 | 60 | async fn serve_docs(Extension(api): Extension>) -> Response { 61 | Json(serde_json::json!(*api)).into_response() 62 | } 63 | 64 | fn api_docs_head_config(api: TransformOpenApi) -> TransformOpenApi { 65 | api.title("Aide axum Open API for axum-restful") 66 | .summary("axum-restful openapi") 67 | } 68 | 69 | fn http_retrieve_summary() -> String { 70 | format!("fetch an instance: {}", Self::modle_schema_description()) 71 | } 72 | 73 | fn http_retrieve_docs(op: TransformOperation) -> TransformOperation { 74 | op.summary(&Self::http_retrieve_summary()) 75 | .response::<200, Json<::Model>>() 76 | } 77 | 78 | fn http_update_summary() -> String { 79 | format!("update an instance {}", Self::modle_schema_description()) 80 | } 81 | 82 | fn http_update_docs(op: TransformOperation) -> TransformOperation { 83 | op.summary(&Self::http_update_summary()) 84 | .response::<200, ()>() 85 | } 86 | 87 | fn http_delete_summary() -> String { 88 | format!("delete an instance {}", Self::modle_schema_description()) 89 | } 90 | 91 | fn http_delete_docs(op: TransformOperation) -> TransformOperation { 92 | op.summary(&Self::http_delete_summary()) 93 | .response::<204, ()>() 94 | } 95 | 96 | fn http_delete_all_summary() -> String { 97 | format!("delete all instances {}", Self::modle_schema_description()) 98 | } 99 | 100 | fn http_delete_all_docs(op: TransformOperation) -> TransformOperation { 101 | op.summary(&Self::http_delete_all_summary()) 102 | .response::<204, ()>() 103 | } 104 | 105 | fn http_list_summary() -> String { 106 | format!("list all instances {}", Self::modle_schema_description()) 107 | } 108 | 109 | fn http_list_docs(op: TransformOperation) -> TransformOperation { 110 | op.summary(&Self::http_list_summary()) 111 | .response::<200, Json::Model>>>() 112 | } 113 | 114 | fn http_create_summary() -> String { 115 | format!("create an instance {}", Self::modle_schema_description()) 116 | } 117 | 118 | fn http_create_docs(op: TransformOperation) -> TransformOperation { 119 | op.summary(&Self::http_create_summary()) 120 | // .input::::Model>>() 121 | .response::<201, ()>() 122 | } 123 | 124 | fn model_api_router() -> ApiRouter { 125 | ApiRouter::new() 126 | .api_route( 127 | "/:id", 128 | get_with(Self::http_retrieve, Self::http_retrieve_docs) 129 | .put_with(Self::http_update, Self::http_update_docs) 130 | .delete_with(Self::http_delete, Self::http_delete_docs), 131 | ) 132 | .api_route( 133 | "/", 134 | get_with(Self::http_list, Self::http_list_docs) 135 | .post_with(Self::http_create, Self::http_create_docs) 136 | .delete_with(Self::http_delete_all, Self::http_delete_all_docs), 137 | ) 138 | } 139 | 140 | async fn http_router_with_swagger( 141 | nest_prefix: &'static str, 142 | model_api_router: ApiRouter, 143 | ) -> anyhow::Result 144 | where 145 | Self: Send + 'static, 146 | { 147 | let mut api = OpenApi::default(); 148 | 149 | awesome_operates::extract_all_files!(awesome_operates::embed::Asset); 150 | awesome_operates::swagger::InitSwagger::new( 151 | awesome_operates::embed::EXTRACT_SWAGGER_DIR_PATH, 152 | "swagger-init.js", 153 | "index.html", 154 | "../api.json", 155 | ) 156 | .build() 157 | .await?; 158 | Ok(ApiRouter::new() 159 | .nest_api_service(nest_prefix, model_api_router) 160 | .nest_service("/swagger", ServeDir::new(Self::serve_dir_path())) 161 | .route("/api.json", get(Self::serve_docs)) 162 | .finish_api_with(&mut api, Self::api_docs_head_config) 163 | .layer(Extension(Arc::new(api)))) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/test_helpers.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | /// mainly copy from axum/test_helpers 3 | use std::net::SocketAddr; 4 | use std::str::FromStr; 5 | 6 | use axum::{extract::Request, response::Response}; 7 | use bytes::Bytes; 8 | use http::{ 9 | header::{HeaderName, HeaderValue}, 10 | StatusCode, 11 | }; 12 | use tokio::net::TcpListener; 13 | use tower::make::Shared; 14 | use tower_service::Service; 15 | 16 | /// A struct for test request 17 | /// ```rust,no_run 18 | /// use axum::{Router, routing::get, http::StatusCode}; 19 | /// use axum_restful::test_helpers::TestClient; 20 | /// 21 | /// let app = Router::new().route("/hello", get(|| async {"Hello, world"})); 22 | /// let client = TestClient::new(app); 23 | /// # async { 24 | /// let res = client.get("/hello").send().await; 25 | /// assert_eq!(res.status(), StatusCode::OK); 26 | /// assert_eq!(res.text().await, "Hello, world"); 27 | /// # }; 28 | /// ``` 29 | /// 30 | pub struct TestClient { 31 | client: reqwest::Client, 32 | addr: SocketAddr, 33 | } 34 | 35 | impl TestClient { 36 | pub fn new(svc: S) -> Self 37 | where 38 | S: Service + Clone + Send + 'static, 39 | S::Future: Send, 40 | { 41 | let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); 42 | std_listener.set_nonblocking(true).unwrap(); 43 | let listener = TcpListener::from_std(std_listener).unwrap(); 44 | 45 | let addr = listener.local_addr().unwrap(); 46 | println!("Listening on {addr}"); 47 | 48 | tokio::spawn(async move { 49 | axum::serve(listener, Shared::new(svc)) 50 | .await 51 | .expect("server error") 52 | }); 53 | 54 | let client = reqwest::Client::builder() 55 | .redirect(reqwest::redirect::Policy::none()) 56 | .build() 57 | .unwrap(); 58 | 59 | TestClient { client, addr } 60 | } 61 | 62 | pub fn get(&self, url: &str) -> RequestBuilder { 63 | RequestBuilder { 64 | builder: self.client.get(format!("http://{}{}", self.addr, url)), 65 | } 66 | } 67 | 68 | pub fn head(&self, url: &str) -> RequestBuilder { 69 | RequestBuilder { 70 | builder: self.client.head(format!("http://{}{}", self.addr, url)), 71 | } 72 | } 73 | 74 | pub fn post(&self, url: &str) -> RequestBuilder { 75 | RequestBuilder { 76 | builder: self.client.post(format!("http://{}{}", self.addr, url)), 77 | } 78 | } 79 | 80 | #[allow(dead_code)] 81 | pub fn put(&self, url: &str) -> RequestBuilder { 82 | RequestBuilder { 83 | builder: self.client.put(format!("http://{}{}", self.addr, url)), 84 | } 85 | } 86 | 87 | #[allow(dead_code)] 88 | pub fn patch(&self, url: &str) -> RequestBuilder { 89 | RequestBuilder { 90 | builder: self.client.patch(format!("http://{}{}", self.addr, url)), 91 | } 92 | } 93 | 94 | #[allow(dead_code)] 95 | pub fn delete(&self, url: &str) -> RequestBuilder { 96 | RequestBuilder { 97 | builder: self.client.delete(format!("http://{}{}", self.addr, url)), 98 | } 99 | } 100 | } 101 | 102 | pub struct RequestBuilder { 103 | builder: reqwest::RequestBuilder, 104 | } 105 | 106 | impl RequestBuilder { 107 | pub async fn send(self) -> TestResponse { 108 | TestResponse { 109 | response: self.builder.send().await.unwrap(), 110 | } 111 | } 112 | 113 | pub fn body(mut self, body: impl Into) -> Self { 114 | self.builder = self.builder.body(body); 115 | self 116 | } 117 | 118 | pub fn json(mut self, json: &T) -> Self 119 | where 120 | T: serde::Serialize, 121 | { 122 | self.builder = self.builder.json(json); 123 | self 124 | } 125 | 126 | pub fn header(mut self, key: K, value: V) -> Self 127 | where 128 | HeaderName: TryFrom, 129 | >::Error: Into, 130 | HeaderValue: TryFrom, 131 | >::Error: Into, 132 | { 133 | // reqwest still uses http 0.2 134 | let key: HeaderName = key.try_into().map_err(Into::into).unwrap(); 135 | let key = reqwest::header::HeaderName::from_bytes(key.as_ref()).unwrap(); 136 | 137 | let value: HeaderValue = value.try_into().map_err(Into::into).unwrap(); 138 | let value = reqwest::header::HeaderValue::from_bytes(value.as_bytes()).unwrap(); 139 | 140 | self.builder = self.builder.header(key, value); 141 | 142 | self 143 | } 144 | 145 | #[allow(dead_code)] 146 | pub fn multipart(mut self, form: reqwest::multipart::Form) -> Self { 147 | self.builder = self.builder.multipart(form); 148 | self 149 | } 150 | } 151 | 152 | #[derive(Debug)] 153 | pub struct TestResponse { 154 | response: reqwest::Response, 155 | } 156 | 157 | impl TestResponse { 158 | #[allow(dead_code)] 159 | pub async fn bytes(self) -> Bytes { 160 | self.response.bytes().await.unwrap() 161 | } 162 | 163 | pub async fn text(self) -> String { 164 | self.response.text().await.unwrap() 165 | } 166 | 167 | #[allow(dead_code)] 168 | pub async fn json(self) -> T 169 | where 170 | T: serde::de::DeserializeOwned, 171 | { 172 | self.response.json().await.unwrap() 173 | } 174 | 175 | pub fn status(&self) -> StatusCode { 176 | StatusCode::from_u16(self.response.status().as_u16()).unwrap() 177 | } 178 | 179 | pub fn headers(&self) -> http::HeaderMap { 180 | // reqwest still uses http 0.2 so have to convert into http 1.0 181 | let mut headers = http::HeaderMap::new(); 182 | for (key, value) in self.response.headers() { 183 | let key = http::HeaderName::from_str(key.as_str()).unwrap(); 184 | let value = http::HeaderValue::from_bytes(value.as_bytes()).unwrap(); 185 | headers.insert(key, value); 186 | } 187 | headers 188 | } 189 | 190 | pub async fn chunk(&mut self) -> Option { 191 | self.response.chunk().await.unwrap() 192 | } 193 | 194 | pub async fn chunk_text(&mut self) -> Option { 195 | let chunk = self.chunk().await?; 196 | Some(String::from_utf8(chunk.to_vec()).unwrap()) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /examples/demo/src/check.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use axum::Router; 3 | use chrono::SubsecRound; 4 | use once_cell::sync::Lazy; 5 | use sea_orm::{DatabaseConnection, EntityTrait, QueryOrder}; 6 | 7 | use axum_restful::test_helpers::TestClient; 8 | 9 | use crate::entities::student; 10 | 11 | const INSTANCE_LEN: usize = 10; 12 | 13 | static BASIC_MODELS: Lazy> = Lazy::new(|| { 14 | let mut models = vec![]; 15 | for i in 1..INSTANCE_LEN + 1 { 16 | models.push(student::Model { 17 | id: i as i64, 18 | // keep id is 1 19 | name: format!("check name {i}"), 20 | region: format!("check region {i}"), 21 | age: 1 + (i as i16), 22 | create_time: chrono::Local::now().naive_local().trunc_subsecs(3), 23 | score: i as f64 / 2.0, 24 | gender: (i as u64 % 2).eq(&0), 25 | }); 26 | } 27 | models 28 | }); 29 | 30 | struct HTTPOperateCheck { 31 | pub client: TestClient, 32 | pub path: String, 33 | pub db: &'static DatabaseConnection, 34 | } 35 | 36 | impl HTTPOperateCheck { 37 | #[inline] 38 | fn path(&self) -> &str { 39 | &self.path 40 | } 41 | 42 | async fn check_create(&self, check_equal: bool) { 43 | tracing::info!("http ceate check"); 44 | for model in BASIC_MODELS.iter() { 45 | let body = serde_json::json!(model); 46 | let res = self.client.post(self.path()).json(&body).send().await; 47 | assert_eq!(res.status(), StatusCode::CREATED); 48 | if check_equal { 49 | let query_model = student::Entity::find() 50 | .order_by_desc(student::Column::Id) 51 | .one(self.db) 52 | .await 53 | .unwrap() 54 | .unwrap(); 55 | assert_eq!(&query_model, model); 56 | } 57 | } 58 | } 59 | 60 | async fn check_list(&self) { 61 | tracing::info!("http list check"); 62 | let res = self.client.get(self.path()).send().await; 63 | assert_eq!(res.status(), StatusCode::OK); 64 | let models = res.json::>().await; 65 | assert_eq!(models.len(), INSTANCE_LEN); 66 | let original_models = BASIC_MODELS.iter().rev().collect::>(); 67 | for (index, model) in models.iter().enumerate() { 68 | assert_eq!(model, original_models[index]); 69 | } 70 | let page_size = 3; 71 | for page_num in 1..3 { 72 | let resp = self 73 | .client 74 | .get(&format!( 75 | "{}?page_size={page_size}&page_num={page_num}", 76 | self.path() 77 | )) 78 | .send() 79 | .await; 80 | assert_eq!(resp.status(), StatusCode::OK); 81 | let results = resp.json::>().await; 82 | assert_eq!(results.len(), page_size); 83 | let start = (page_num - 1) * page_size; 84 | assert_eq!( 85 | results.iter().collect::>()[..], 86 | original_models[start..start + page_size] 87 | ); 88 | } 89 | } 90 | 91 | async fn check_retrive(&self) { 92 | tracing::info!("http retrive check"); 93 | for model in BASIC_MODELS.iter() { 94 | self.check_db_model_eq(model).await; 95 | } 96 | } 97 | 98 | async fn check_db_model_eq(&self, model: &student::Model) { 99 | let path = format!("{}/{}", self.path(), model.id); 100 | let res = self.client.get(&path).send().await; 101 | assert_eq!(res.status(), StatusCode::OK); 102 | assert_eq!(model, &res.json::().await); 103 | } 104 | 105 | async fn reset_all_models(&self) { 106 | tracing::info!("reset all models"); 107 | for model in BASIC_MODELS.iter() { 108 | let detail_path = format!("{}/{}", self.path(), model.id); 109 | let res = self.client.put(&detail_path).json(model).send().await; 110 | assert_eq!(res.status(), StatusCode::OK); 111 | self.check_db_model_eq(model).await; 112 | } 113 | } 114 | 115 | async fn check_put(&self) { 116 | tracing::info!("http put check"); 117 | for model in BASIC_MODELS.iter() { 118 | let mut put_model = model.clone(); 119 | // check wrong body id 120 | put_model.id *= 10; 121 | tracing::info!("check wrong id {}", put_model.id); 122 | put_model.name = format!("changed {}", put_model.name); 123 | put_model.region = format!("changed {} region", put_model.region); 124 | put_model.age *= 2; 125 | put_model.create_time = chrono::Local::now().naive_local().trunc_subsecs(3); 126 | put_model.score += 99.0; 127 | put_model.gender = !put_model.gender; 128 | assert_ne!(model, &put_model); 129 | // check change all the fields 130 | let detail_path = format!("{}/{}", self.path(), model.id); 131 | let res = self.client.put(&detail_path).json(&put_model).send().await; 132 | assert_eq!(res.status(), StatusCode::OK); 133 | put_model.id = model.id; 134 | tracing::info!("put {}", serde_json::json!(put_model)); 135 | self.check_db_model_eq(&put_model).await; 136 | } 137 | let resp = self 138 | .client 139 | .put(&format!("{}/{}", self.path(), u16::MAX)) 140 | .json(BASIC_MODELS.iter().next().unwrap()) 141 | .send() 142 | .await; 143 | assert_eq!(resp.status(), StatusCode::NOT_FOUND); 144 | } 145 | 146 | async fn check_delete(&self) { 147 | tracing::info!("http delete single check"); 148 | for model in BASIC_MODELS.iter() { 149 | let detail_path = format!("{}/{}", self.path(), model.id); 150 | let resp = self.client.delete(&detail_path).send().await; 151 | assert_eq!(resp.status(), StatusCode::NO_CONTENT); 152 | let resp = self.client.get(&detail_path).send().await; 153 | assert_eq!(resp.status(), StatusCode::NOT_FOUND); 154 | } 155 | self.check_is_empty().await; 156 | let resp = self 157 | .client 158 | .delete(&format!("{}/{}", self.path(), u16::MAX)) 159 | .json(BASIC_MODELS.iter().next().unwrap()) 160 | .send() 161 | .await; 162 | assert_eq!(resp.status(), StatusCode::NOT_FOUND); 163 | 164 | tracing::info!("recreate all"); 165 | self.check_create(false).await; 166 | let resp = self.client.delete(self.path()).send().await; 167 | assert_eq!(resp.status(), StatusCode::NO_CONTENT); 168 | self.check_is_empty().await; 169 | } 170 | 171 | async fn check_is_empty(&self) { 172 | let resp = self 173 | .client 174 | .get(self.path()) 175 | .send() 176 | .await 177 | .json::>() 178 | .await; 179 | assert!(resp.is_empty()); 180 | } 181 | } 182 | 183 | pub async fn check_curd_operate_correct(app: Router, path: &str, db: &'static DatabaseConnection) { 184 | let c = HTTPOperateCheck { 185 | client: TestClient::new(app.clone()), 186 | path: path.to_owned(), 187 | db, 188 | }; 189 | tracing::warn!("check for curd, this will generate some error level `PrimaryKeyNotFound`, it's for check, please ignore it."); 190 | c.check_create(true).await; 191 | c.check_list().await; 192 | c.check_retrive().await; 193 | c.check_put().await; 194 | c.reset_all_models().await; 195 | c.check_delete().await; 196 | } 197 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /src/views/operates.rs: -------------------------------------------------------------------------------- 1 | use std::any::type_name; 2 | 3 | use async_trait::async_trait; 4 | use axum::extract::Query; 5 | use axum::{ 6 | extract::Path, 7 | http::StatusCode, 8 | response::{IntoResponse, Response}, 9 | routing::get, 10 | Json, Router, 11 | }; 12 | use sea_orm::{ 13 | ActiveModelBehavior, ActiveModelTrait, DatabaseConnection, EntityTrait, IntoActiveModel, 14 | Iterable, ModelTrait, PaginatorTrait, PrimaryKeyToColumn, PrimaryKeyTrait, QueryOrder, 15 | TryFromU64, 16 | }; 17 | use serde::Serialize; 18 | use serde_json::Value; 19 | use snafu::{OptionExt, ResultExt}; 20 | 21 | use crate::error::{OperateDatabaseSnafu, PrimaryKeyNotFoundSnafu}; 22 | use crate::{db, error::Result, generate_by_params}; 23 | 24 | #[async_trait] 25 | pub trait ModelViewExt 26 | where 27 | T: ActiveModelTrait + ActiveModelBehavior + Send + 'static + Sync, 28 | ::Model: IntoActiveModel + Serialize + Sync, 29 | for<'de> ::Model: serde::de::Deserialize<'de>, 30 | { 31 | #[inline] 32 | fn modle_name() -> String { 33 | let name = type_name::().to_lowercase(); 34 | match name.rsplit_once("::") { 35 | None => name, 36 | Some((_, last)) => last.to_owned(), 37 | } 38 | } 39 | 40 | /// get default db connection with default config 41 | /// you can change this when impl ModelView 42 | async fn get_db_connection() -> &'static DatabaseConnection { 43 | db::get_db_connection_pool().await 44 | } 45 | 46 | /// POST a json body to /api and create a line in database 47 | /// return http 201 StatusCode::CREATED 48 | async fn http_create( 49 | Json(data): Json<::Model>, 50 | ) -> Result { 51 | let mut active_model: T = data.into_active_model(); 52 | tracing::debug!( 53 | "[{}] http create: before not set pk {active_model:?}", 54 | Self::modle_name() 55 | ); 56 | for key in ::PrimaryKey::iter() { 57 | let col = key.into_column(); 58 | active_model.not_set(col); 59 | } 60 | tracing::debug!( 61 | "[{}] http create: active model is {active_model:?}", 62 | Self::modle_name() 63 | ); 64 | let result = active_model 65 | .insert(Self::get_db_connection().await) 66 | .await 67 | .context(OperateDatabaseSnafu)?; 68 | tracing::debug!( 69 | "[{}] http create: create model {result:?}", 70 | Self::modle_name() 71 | ); 72 | Ok(StatusCode::CREATED) 73 | } 74 | 75 | fn set_model_primary_key(active_model: &mut T, value: u64) { 76 | if let Some(key) = ::PrimaryKey::iter().next() { 77 | let col = key.into_column(); 78 | active_model.set(col, sea_orm::Value::BigInt(Some(value as i64))); 79 | } 80 | } 81 | 82 | /// PUT a json body to /api/:id 83 | /// change a line in database 84 | /// return http 200 StatusCode::OK 85 | async fn http_update( 86 | Path(pk): Path, 87 | Json(data): Json<::Model>, 88 | ) -> Result { 89 | tracing::debug!("[{}] http update check: {pk}", Self::modle_name()); 90 | Self::check_instance_exists(pk).await?; 91 | let mut active_model = data.into_active_model().reset_all(); 92 | Self::set_model_primary_key(&mut active_model, pk); 93 | tracing::debug!( 94 | "[{}] http update: active pk: {pk} active model: {active_model:?}", 95 | Self::modle_name() 96 | ); 97 | let result = active_model 98 | .update(Self::get_db_connection().await) 99 | .await 100 | .context(OperateDatabaseSnafu)?; 101 | tracing::debug!("[{}] http update: result {result:?}", Self::modle_name()); 102 | Ok(StatusCode::OK) 103 | } 104 | 105 | async fn check_instance_exists(pk: u64) -> Result<::Model> { 106 | Ok( 107 | ::find_by_id(Self::exchange_primary_key(pk)) 108 | .one(Self::get_db_connection().await) 109 | .await 110 | .context(OperateDatabaseSnafu)? 111 | .context(PrimaryKeyNotFoundSnafu { pk })?, 112 | ) 113 | } 114 | 115 | /// TODO: patch need value trans into active model value 116 | /// PATCH a json body to /api/:id 117 | /// return http 200 StatusCode::OK if matched, or return 404 if not matched a query 118 | // async fn http_partial_update( 119 | // Path(pk): Path, 120 | // Json(data): Json, 121 | // ) -> Result { 122 | // tracing::debug!("[{}] http patch check: {pk}", Self::modle_name()); 123 | // let model = Self::check_instance_exists(pk).await?; 124 | // let mut active_model = model.into_active_model(); 125 | // tracing::debug!( 126 | // "[{}] http patch: original pk: {pk} active model: {active_model:?}", 127 | // Self::modle_name() 128 | // ); 129 | // for (k, v) in data.as_object().unwrap() { 130 | // tracing::debug!("[{}] http patch set {}: {}", Self::modle_name(), k, v); 131 | // if let Ok(column) = ::Column::from_str(k) { 132 | // // let col_type = column.def().get_column_type() 133 | // // active_model.set(column, v); 134 | // } 135 | // } 136 | // tracing::debug!( 137 | // "[{}] http patch: ative model pk: {pk} active model: {active_model:?}", 138 | // Self::modle_name() 139 | // ); 140 | // let result = active_model.update(Self::get_db_connection().await).await; 141 | // tracing::debug!("[{}] http patch: result {result:?}", Self::modle_name()); 142 | // Ok(StatusCode::OK) 143 | // } 144 | 145 | fn order_by_desc() -> ::Column; 146 | 147 | /// GET list results with /api 148 | /// you can set page_size and page_num to page results with url like /api?page_size=10 or /api?page_size=10&page_num=1 149 | /// return results with StatusCode::OK 150 | async fn http_list(Query(query): Query) -> Result> { 151 | let db = Self::get_db_connection().await; 152 | let page_size = Self::get_page_size(&query); 153 | let results = if !page_size.eq(&0) { 154 | T::Entity::find() 155 | .order_by_desc(Self::order_by_desc()) 156 | .into_model() 157 | .paginate(db, page_size) 158 | .fetch_page(Self::get_page_num(&query)) 159 | .await 160 | .context(OperateDatabaseSnafu)? 161 | } else { 162 | tracing::debug!("http list: fetch all"); 163 | ::find() 164 | .order_by_desc(Self::order_by_desc()) 165 | .all(Self::get_db_connection().await) 166 | .await 167 | .context(OperateDatabaseSnafu)? 168 | }; 169 | tracing::debug!("http list: fetch results len {}", results.len()); 170 | Ok(Json(serde_json::json!(results))) 171 | } 172 | 173 | /// GET a single query result with /api/:id 174 | /// return http 200 with result or 404 if query not matched 175 | async fn http_retrieve(Path(pk): Path) -> Result { 176 | tracing::debug!("[{}] http retrive: pk: {pk}", Self::modle_name()); 177 | Ok(Json( 178 | ::find_by_id(Self::exchange_primary_key(pk)) 179 | .one(Self::get_db_connection().await) 180 | .await 181 | .context(OperateDatabaseSnafu)? 182 | .context(PrimaryKeyNotFoundSnafu { pk })?, 183 | ) 184 | .into_response()) 185 | } 186 | 187 | /// DELETE a instance with /api/:id 188 | /// return http 204 if success delete or http 404 if not matched or http 500 with error info 189 | async fn http_delete(Path(pk): Path) -> Result { 190 | let db = Self::get_db_connection().await; 191 | tracing::debug!("[{}] http delete: pk: {pk}", Self::modle_name()); 192 | ::find_by_id(Self::exchange_primary_key(pk)) 193 | .one(db) 194 | .await 195 | .context(OperateDatabaseSnafu)? 196 | .context(PrimaryKeyNotFoundSnafu { pk })? 197 | .delete(db) 198 | .await 199 | .context(OperateDatabaseSnafu)?; 200 | tracing::debug!("[{}] http delete: success pk: {pk}", Self::modle_name()); 201 | Ok(StatusCode::NO_CONTENT) 202 | } 203 | 204 | async fn http_delete_all() -> Result { 205 | let db = Self::get_db_connection().await; 206 | tracing::debug!("[{}] http delete all", Self::modle_name()); 207 | ::delete_many() 208 | .exec(db) 209 | .await 210 | .context(OperateDatabaseSnafu)?; 211 | tracing::debug!("[{}] http delete all success", Self::modle_name()); 212 | Ok(StatusCode::NO_CONTENT) 213 | } 214 | 215 | /// change a value u64 into primary key 216 | #[inline] 217 | fn exchange_primary_key( 218 | id: u64, 219 | ) -> <::PrimaryKey as PrimaryKeyTrait>::ValueType { 220 | <::PrimaryKey as PrimaryKeyTrait>::ValueType::try_from_u64(id) 221 | .unwrap() 222 | } 223 | 224 | generate_by_params! {size, "size", 20} 225 | generate_by_params! {num, "num", 0, 1} 226 | 227 | /// get http routers with full operates 228 | fn http_router(nest_prefix: &'static str) -> Router 229 | where 230 | Self: Send + 'static, 231 | { 232 | Router::new().nest( 233 | nest_prefix, 234 | Router::new() 235 | .route( 236 | "/:id", 237 | get(Self::http_retrieve) 238 | .put(Self::http_update) 239 | .delete(Self::http_delete), 240 | ) 241 | .route( 242 | "/", 243 | get(Self::http_list) 244 | .post(Self::http_create) 245 | .delete(Self::http_delete_all), 246 | ), 247 | ) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## `axum-restful` 2 | 3 | A restful framework based on `axum` and `sea-orm`. Inspired by `django-rest-framework`. 4 | 5 | The goal of the project is to build an enterprise-level production framework. 6 | 7 | ## Features 8 | 9 | - a Trait for the `struct` generated by `sea-orm` to provide with GET, PUT, DELETE methods 10 | - `tls` support 11 | - `prometheus` metrics and metrics server 12 | - `graceful shutdown`support 13 | - `swagger document` generate based on [`aide`](https://github.com/tamasfe/aide) 14 | 15 | ## Quick start 16 | 17 | A full example is exists at `axum-restful/examples/demo`. 18 | 19 | First, you can create a new crate like `cargo new axum-restful-demo`. 20 | 21 | #### Build a database service 22 | 23 | You should have a database service before. It is recommended to use `postgresql` database. 24 | 25 | you can use docker and docker compose to start a `postgresql` 26 | 27 | create a `compose.yaml` in the same directory as `Cargo.toml` 28 | 29 | ```yaml 30 | services: 31 | postgres: 32 | image: postgres:15-bullseye 33 | container_name: demo-postgres 34 | restart: always 35 | volumes: 36 | - demo-postgres:/var/lib/postgresql/data 37 | ports: 38 | - "127.0.0.1:5432:5432" 39 | environment: 40 | - POSTGRES_DB=${POSTGRES_DB} 41 | - POSTGRES_USER=${POSTGRES_USER} 42 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 43 | 44 | volumes: 45 | demo-postgres: {} 46 | ``` 47 | 48 | a `.env`file like 49 | 50 | ``` 51 | # config the base pg connect params 52 | POSTGRES_DB=demo 53 | POSTGRES_USER=demo-user 54 | POSTGRES_PASSWORD=demo-password 55 | 56 | # used by axum-restful framework to specific a database connection 57 | DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB} 58 | ``` 59 | 60 | finally, you can build a service with `docker compose up -d` 61 | 62 | #### Write and migrate a migration 63 | 64 | For more details, please refer to the [`sea-orm`](https://www.sea-ql.org/SeaORM/docs/index/) documentation. 65 | 66 | Install the `sea-orm-cli` with `cargo` 67 | 68 | ```shell 69 | $ cargo install sea-orm-cli 70 | ``` 71 | 72 | Configure dependencies and workspace in `Cargo.toml` 73 | 74 | ```toml 75 | [package] 76 | name = "demo" 77 | version = "0.1.0" 78 | edition = "2021" 79 | 80 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 81 | [workspace] 82 | members = [".", "migration"] 83 | 84 | [dependencies] 85 | aide = "0.13" 86 | axum = "0.7" 87 | axum-restful = "0.5" 88 | chrono = "0.4" 89 | migration = { path = "./migration" } 90 | once_cell = "1" 91 | schemars = { version = "0.8", features = ["chrono"] } 92 | sea-orm = { version = "0.12", features = ["macros", "sqlx-postgres", "runtime-tokio-rustls"] } 93 | sea-orm-migration = { version = "0.12", features = ["sqlx-postgres", "runtime-tokio-rustls",] } 94 | serde = { version = "1.0", features = ["derive"] } 95 | serde_json = "1.0" 96 | tokio = { version = "1", features = ["full"] } 97 | tracing = "0.1" 98 | tracing-subscriber = "0.3" 99 | ``` 100 | 101 | Setup the migration directory in `./migration` 102 | 103 | ```shell 104 | $ sea-orm-cli migrate init 105 | ``` 106 | 107 | project structure changed into 108 | 109 | ``` 110 | ├── Cargo.lock 111 | ├── Cargo.toml 112 | ├── compose.yaml 113 | ├── migration 114 | │   ├── Cargo.toml 115 | │   ├── README.md 116 | │   └── src 117 | │   ├── lib.rs 118 | │   ├── m20220101_000001_create_table.rs 119 | │   └── main.rs 120 | └── src 121 | └── main.rs 122 | ``` 123 | 124 | edit the `m20****_******_create_table.rs` file blow `./migration/src` 125 | 126 | ```rust 127 | use sea_orm_migration::prelude::*; 128 | 129 | #[derive(DeriveMigrationName)] 130 | pub struct Migration; 131 | 132 | #[async_trait::async_trait] 133 | impl MigrationTrait for Migration { 134 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 135 | // Replace the sample below with your own migration scripts 136 | manager 137 | .create_table( 138 | Table::create() 139 | .table(Student::Table) 140 | .if_not_exists() 141 | .col( 142 | ColumnDef::new(Student::Id) 143 | .big_integer() 144 | .not_null() 145 | .auto_increment() 146 | .primary_key(), 147 | ) 148 | .col(ColumnDef::new(Student::Name).string().not_null()) 149 | .col(ColumnDef::new(Student::Region).string().not_null()) 150 | .col(ColumnDef::new(Student::Age).small_integer().not_null()) 151 | .col(ColumnDef::new(Student::CreateTime).date_time().not_null()) 152 | .col(ColumnDef::new(Student::Score).double().not_null()) 153 | .col( 154 | ColumnDef::new(Student::Gender) 155 | .boolean() 156 | .not_null() 157 | .default(Expr::value(true)), 158 | ) 159 | .to_owned(), 160 | ) 161 | .await 162 | } 163 | 164 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 165 | // Replace the sample below with your own migration scripts 166 | 167 | manager 168 | .drop_table(Table::drop().table(Student::Table).to_owned()) 169 | .await 170 | } 171 | } 172 | 173 | /// Learn more at https://docs.rs/sea-query#iden 174 | #[derive(Iden)] 175 | enum Student { 176 | Table, 177 | Id, 178 | Name, 179 | Region, 180 | Age, 181 | CreateTime, 182 | Score, 183 | Gender, 184 | } 185 | ``` 186 | 187 | edit `migration/Cargo.toml` to add dependencies 188 | 189 | ```toml 190 | [dependencies] 191 | ... 192 | axum-restful = "0.5" 193 | ``` 194 | 195 | edit `migration/src/main.rs` to specific a database connection an migrate 196 | 197 | ```rust 198 | use sea_orm_migration::prelude::*; 199 | 200 | #[async_std::main] 201 | async fn main() { 202 | // cli::run_cli(migration::Migrator).await; 203 | let db = axum_restful::get_db_connection_pool().await; 204 | migration::Migrator::up(db, None).await.unwrap(); 205 | } 206 | ``` 207 | 208 | migrate the migration files 209 | 210 | ```shell 211 | $ cd migration 212 | $ cargo run 213 | ``` 214 | 215 | finally, you can see two tables named `sql_migrations` and `student`generated. 216 | 217 | #### Generate entities 218 | 219 | at the project root path 220 | 221 | ```shell 222 | $ sea-orm-cli generate entity -o src/entities 223 | ``` 224 | 225 | will generate entities configure and code, now project structure changed into 226 | 227 | ``` 228 | ├── Cargo.lock 229 | ├── Cargo.toml 230 | ├── compose.yaml 231 | ├── migration 232 | │   ├── Cargo.toml 233 | │   ├── README.md 234 | │   └── src 235 | │   ├── lib.rs 236 | │   ├── m20220101_000001_create_table.rs 237 | │   └── main.rs 238 | └── src 239 | ├── entities 240 | │   ├── mod.rs 241 | │   ├── prelude.rs 242 | │   └── student.rs 243 | └── main.rs 244 | ``` 245 | 246 | edit the `src/entities/student.rs` to add derive `Default, Serialize, Deserialize` 247 | 248 | ```rust 249 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.0 250 | 251 | use schemars::JsonSchema; 252 | use sea_orm::entity::prelude::*; 253 | use serde::{Deserialize, Serialize}; 254 | 255 | #[derive(Clone, Debug, PartialEq, JsonSchema, DeriveEntityModel, Serialize, Deserialize)] 256 | #[sea_orm(table_name = "student")] 257 | pub struct Model { 258 | #[sea_orm(primary_key)] 259 | pub id: i64, 260 | pub name: String, 261 | pub region: String, 262 | pub age: i16, 263 | pub create_time: DateTime, 264 | #[sea_orm(column_type = "Double")] 265 | pub score: f64, 266 | pub gender: bool, 267 | } 268 | 269 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 270 | pub enum Relation {} 271 | 272 | impl ActiveModelBehavior for ActiveModel {} 273 | 274 | ``` 275 | 276 | edit `src/main.rs` 277 | 278 | ```rust 279 | use schemars::JsonSchema; 280 | use sea_orm_migration::prelude::MigratorTrait; 281 | use tokio::net::TcpListener; 282 | 283 | use axum_restful::swagger::SwaggerGeneratorExt; 284 | use axum_restful::views::ModelViewExt; 285 | 286 | use crate::entities::student; 287 | 288 | mod check; 289 | mod entities; 290 | 291 | #[tokio::main] 292 | async fn main() { 293 | tracing_subscriber::fmt::init(); 294 | let db = axum_restful::get_db_connection_pool().await; 295 | let _ = migration::Migrator::down(db, None).await; 296 | migration::Migrator::up(db, None).await.unwrap(); 297 | tracing::info!("migrate success"); 298 | 299 | aide::gen::on_error(|error| { 300 | tracing::error!("swagger api gen error: {error}"); 301 | }); 302 | aide::gen::extract_schemas(true); 303 | 304 | /// student 305 | #[derive(JsonSchema)] 306 | struct StudentView; 307 | 308 | impl ModelViewExt for StudentView { 309 | fn order_by_desc() -> student::Column { 310 | student::Column::Id 311 | } 312 | } 313 | 314 | let path = "/api/student"; 315 | let app = StudentView::http_router(path); 316 | check::check_curd_operate_correct(app.clone(), path, db).await; 317 | 318 | // if you want to generate swagger docs 319 | // impl OperationInput and SwaggerGenerator and change app into http_routers_with_swagger 320 | impl aide::operation::OperationInput for student::Model {} 321 | impl axum_restful::swagger::SwaggerGeneratorExt for StudentView {} 322 | let app = StudentView::http_router_with_swagger(path, StudentView::model_api_router()).await.unwrap(); 323 | 324 | let addr = "0.0.0.0:3000"; 325 | tracing::info!("listen at {addr}"); 326 | tracing::info!("visit http://127.0.0.1:3000/docs/swagger/ for swagger api"); 327 | let listener = TcpListener::bind(addr).await.unwrap(); 328 | axum::serve(listener, app.into_make_service()).await.unwrap(); 329 | } 330 | 331 | ``` 332 | 333 | `StudentView impl the ModelView`, the `T` is `student::ActiveModel` that represent the `student table configure` in the database, if will has full HTTP methods with GET, POST, PUT, DELETE. 334 | 335 | you can see the server is listen at port 3000 336 | 337 | #### Verify the service 338 | 339 | #### Swagger 340 | 341 | if you `impl axum_restful::swagger::SwaggerGenerator` above, then you can visit `http://127.0.0.1:3000/docs/swagger/` at your browser, you will see a swagger document is generated 342 | 343 | ![swagger-ui](https://github.com/gongzhengyang/axum-restful/blob/main/statics/swagger-ui-demo.png) 344 | 345 | ## License 346 | 347 | 348 | Licensed under either of 349 | 350 | - Apache License, Version 2.0 351 | ([LICENSE-APACHE](LICENSE-APACHE) or ) 352 | - MIT license 353 | ([LICENSE-MIT](LICENSE-MIT) or ) 354 | 355 | at your option. 356 | 357 | ### Contribution 358 | 359 | Unless you explicitly state otherwise, any contribution intentionally submitted 360 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 361 | dual licensed as above, without any additional terms or conditions. --------------------------------------------------------------------------------