diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d4c11de96..037d21def 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -405,6 +405,15 @@ jobs: args: > --manifest-path ${{ matrix.path }} + - name: Run mock test if it is core crate + uses: actions-rs/cargo@v1 + if: ${{ contains(matrix.path, 'core/Cargo.toml') }} + with: + command: test + args: > + --manifest-path ${{ matrix.path }} + --features mock + - name: check rustfmt run: | rustup override set nightly diff --git a/examples/actix3_example/Cargo.toml b/examples/actix3_example/Cargo.toml index d08d1dcd9..a449de4ca 100644 --- a/examples/actix3_example/Cargo.toml +++ b/examples/actix3_example/Cargo.toml @@ -6,30 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -actix-http = "2" -actix-web = "3" -actix-flash = "0.2" -actix-files = "0.5" -futures = { version = "^0.3" } -futures-util = { version = "^0.3" } -tera = "1.8.0" -dotenv = "0.15" -listenfd = "0.3.3" -serde = "1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-async-std-native-tls", - "sqlx-mysql", - # "sqlx-postgres", - # "sqlx-sqlite", -] +actix3-example-api = { path = "api" } diff --git a/examples/actix3_example/README.md b/examples/actix3_example/README.md index 2cd8c3584..472638261 100644 --- a/examples/actix3_example/README.md +++ b/examples/actix3_example/README.md @@ -6,7 +6,7 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-mysql",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-mysql",` line) 1. Execute `cargo run` to start the server @@ -18,3 +18,10 @@ Run server with auto-reloading: cargo install systemfd systemfd --no-pid -s http::8000 -- cargo watch -x run ``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/actix3_example/api/Cargo.toml b/examples/actix3_example/api/Cargo.toml new file mode 100644 index 000000000..5f38712e0 --- /dev/null +++ b/examples/actix3_example/api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "actix3-example-api" +version = "0.1.0" +authors = ["Sam Samai "] +edition = "2021" +publish = false + +[dependencies] +actix3-example-core = { path = "../core" } +actix-http = "2" +actix-web = "3" +actix-flash = "0.2" +actix-files = "0.5" +futures = { version = "^0.3" } +futures-util = { version = "^0.3" } +tera = "1.8.0" +dotenv = "0.15" +listenfd = "0.3.3" +serde = "1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/actix3_example/api/src/lib.rs b/examples/actix3_example/api/src/lib.rs new file mode 100644 index 000000000..275e4a7cc --- /dev/null +++ b/examples/actix3_example/api/src/lib.rs @@ -0,0 +1,219 @@ +use actix3_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; +use actix_files as fs; +use actix_web::{ + error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, +}; + +use entity::post; +use listenfd::ListenFd; +use migration::{Migrator, MigratorTrait}; +use serde::{Deserialize, Serialize}; +use std::env; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} +#[derive(Debug, Deserialize)] +pub struct Params { + page: Option, + posts_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +#[get("/")] +async fn list( + req: HttpRequest, + data: web::Data, + opt_flash: Option>, +) -> Result { + let template = &data.templates; + let conn = &data.conn; + + // get params + let params = web::Query::::from_query(req.query_string()).unwrap(); + + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = Query::find_posts_in_page(conn, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + if let Some(flash) = opt_flash { + let flash_inner = flash.into_inner(); + ctx.insert("flash", &flash_inner); + } + + let body = template + .render("index.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[get("/new")] +async fn new(data: web::Data) -> Result { + let template = &data.templates; + let ctx = tera::Context::new(); + let body = template + .render("new.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/")] +async fn create( + data: web::Data, + post_form: web::Form, +) -> actix_flash::Response { + let conn = &data.conn; + + let form = post_form.into_inner(); + + Mutation::create_post(conn, form) + .await + .expect("could not insert post"); + + let flash = FlashData { + kind: "success".to_owned(), + message: "Post successfully added.".to_owned(), + }; + + actix_flash::Response::with_redirect(flash, "/") +} + +#[get("/{id}")] +async fn edit(data: web::Data, id: web::Path) -> Result { + let conn = &data.conn; + let template = &data.templates; + let id = id.into_inner(); + + let post: post::Model = Query::find_post_by_id(conn, id) + .await + .expect("could not find post") + .unwrap_or_else(|| panic!("could not find post with id {}", id)); + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = template + .render("edit.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/{id}")] +async fn update( + data: web::Data, + id: web::Path, + post_form: web::Form, +) -> actix_flash::Response { + let conn = &data.conn; + let form = post_form.into_inner(); + let id = id.into_inner(); + + Mutation::update_post_by_id(conn, id, form) + .await + .expect("could not edit post"); + + let flash = FlashData { + kind: "success".to_owned(), + message: "Post successfully updated.".to_owned(), + }; + + actix_flash::Response::with_redirect(flash, "/") +} + +#[post("/delete/{id}")] +async fn delete( + data: web::Data, + id: web::Path, +) -> actix_flash::Response { + let conn = &data.conn; + let id = id.into_inner(); + + Mutation::delete_post(conn, id) + .await + .expect("could not delete post"); + + let flash = FlashData { + kind: "success".to_owned(), + message: "Post successfully deleted.".to_owned(), + }; + + actix_flash::Response::with_redirect(flash, "/") +} + +#[actix_web::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + let mut listenfd = ListenFd::from_env(); + let mut server = HttpServer::new(move || { + App::new() + .data(state.clone()) + .wrap(middleware::Logger::default()) // enable logger + .wrap(actix_flash::Flash::default()) + .configure(init) + .service(fs::Files::new("/static", "./api/static").show_files_listing()) + }); + + server = match listenfd.take_tcp_listener(0)? { + Some(listener) => server.listen(listener)?, + None => server.bind(&server_url)?, + }; + + println!("Starting server at {}", server_url); + server.run().await?; + + Ok(()) +} + +fn init(cfg: &mut web::ServiceConfig) { + cfg.service(list); + cfg.service(new); + cfg.service(create); + cfg.service(edit); + cfg.service(update); + cfg.service(delete); +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err) + } +} diff --git a/examples/actix3_example/static/css/normalize.css b/examples/actix3_example/api/static/css/normalize.css similarity index 100% rename from examples/actix3_example/static/css/normalize.css rename to examples/actix3_example/api/static/css/normalize.css diff --git a/examples/actix3_example/static/css/skeleton.css b/examples/actix3_example/api/static/css/skeleton.css similarity index 100% rename from examples/actix3_example/static/css/skeleton.css rename to examples/actix3_example/api/static/css/skeleton.css diff --git a/examples/actix3_example/static/css/style.css b/examples/actix3_example/api/static/css/style.css similarity index 100% rename from examples/actix3_example/static/css/style.css rename to examples/actix3_example/api/static/css/style.css diff --git a/examples/actix3_example/static/images/favicon.png b/examples/actix3_example/api/static/images/favicon.png similarity index 100% rename from examples/actix3_example/static/images/favicon.png rename to examples/actix3_example/api/static/images/favicon.png diff --git a/examples/actix3_example/templates/edit.html.tera b/examples/actix3_example/api/templates/edit.html.tera similarity index 100% rename from examples/actix3_example/templates/edit.html.tera rename to examples/actix3_example/api/templates/edit.html.tera diff --git a/examples/actix3_example/templates/error/404.html.tera b/examples/actix3_example/api/templates/error/404.html.tera similarity index 100% rename from examples/actix3_example/templates/error/404.html.tera rename to examples/actix3_example/api/templates/error/404.html.tera diff --git a/examples/actix3_example/templates/index.html.tera b/examples/actix3_example/api/templates/index.html.tera similarity index 100% rename from examples/actix3_example/templates/index.html.tera rename to examples/actix3_example/api/templates/index.html.tera diff --git a/examples/actix3_example/templates/layout.html.tera b/examples/actix3_example/api/templates/layout.html.tera similarity index 100% rename from examples/actix3_example/templates/layout.html.tera rename to examples/actix3_example/api/templates/layout.html.tera diff --git a/examples/actix3_example/templates/new.html.tera b/examples/actix3_example/api/templates/new.html.tera similarity index 100% rename from examples/actix3_example/templates/new.html.tera rename to examples/actix3_example/api/templates/new.html.tera diff --git a/examples/actix3_example/core/Cargo.toml b/examples/actix3_example/core/Cargo.toml new file mode 100644 index 000000000..c0548ff49 --- /dev/null +++ b/examples/actix3_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "actix3-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + "sqlx-mysql", + # "sqlx-postgres", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/actix3_example/core/src/lib.rs b/examples/actix3_example/core/src/lib.rs new file mode 100644 index 000000000..4a80f2391 --- /dev/null +++ b/examples/actix3_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/actix3_example/core/src/mutation.rs b/examples/actix3_example/core/src/mutation.rs new file mode 100644 index 000000000..dd6891d4a --- /dev/null +++ b/examples/actix3_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/actix3_example/core/src/query.rs b/examples/actix3_example/core/src/query.rs new file mode 100644 index 000000000..e8d2668f5 --- /dev/null +++ b/examples/actix3_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/actix3_example/core/tests/mock.rs b/examples/actix3_example/core/tests/mock.rs new file mode 100644 index 000000000..190cb2906 --- /dev/null +++ b/examples/actix3_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use actix3_example_core::{Mutation, Query}; +use entity::post; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/actix3_example/core/tests/prepare.rs b/examples/actix3_example/core/tests/prepare.rs new file mode 100644 index 000000000..451804937 --- /dev/null +++ b/examples/actix3_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/actix3_example/src/main.rs b/examples/actix3_example/src/main.rs index f6ac5d6bb..5a0e63b82 100644 --- a/examples/actix3_example/src/main.rs +++ b/examples/actix3_example/src/main.rs @@ -1,227 +1,3 @@ -use actix_files as fs; -use actix_web::{ - error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, -}; - -use entity::post; -use entity::post::Entity as Post; -use listenfd::ListenFd; -use migration::{Migrator, MigratorTrait}; -use sea_orm::DatabaseConnection; -use sea_orm::{entity::*, query::*}; -use serde::{Deserialize, Serialize}; -use std::env; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} -#[derive(Debug, Deserialize)] -pub struct Params { - page: Option, - posts_per_page: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct FlashData { - kind: String, - message: String, -} - -#[get("/")] -async fn list( - req: HttpRequest, - data: web::Data, - opt_flash: Option>, -) -> Result { - let template = &data.templates; - let conn = &data.conn; - - // get params - let params = web::Query::::from_query(req.query_string()).unwrap(); - - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(conn, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - if let Some(flash) = opt_flash { - let flash_inner = flash.into_inner(); - ctx.insert("flash", &flash_inner); - } - - let body = template - .render("index.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[get("/new")] -async fn new(data: web::Data) -> Result { - let template = &data.templates; - let ctx = tera::Context::new(); - let body = template - .render("new.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/")] -async fn create( - data: web::Data, - post_form: web::Form, -) -> actix_flash::Response { - let conn = &data.conn; - - let form = post_form.into_inner(); - - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(conn) - .await - .expect("could not insert post"); - - let flash = FlashData { - kind: "success".to_owned(), - message: "Post successfully added.".to_owned(), - }; - - actix_flash::Response::with_redirect(flash, "/") -} - -#[get("/{id}")] -async fn edit(data: web::Data, id: web::Path) -> Result { - let conn = &data.conn; - let template = &data.templates; - - let post: post::Model = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .expect("could not find post") - .unwrap(); - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = template - .render("edit.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/{id}")] -async fn update( - data: web::Data, - id: web::Path, - post_form: web::Form, -) -> actix_flash::Response { - let conn = &data.conn; - let form = post_form.into_inner(); - - post::ActiveModel { - id: Set(id.into_inner()), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(conn) - .await - .expect("could not edit post"); - - let flash = FlashData { - kind: "success".to_owned(), - message: "Post successfully updated.".to_owned(), - }; - - actix_flash::Response::with_redirect(flash, "/") -} - -#[post("/delete/{id}")] -async fn delete( - data: web::Data, - id: web::Path, -) -> actix_flash::Response { - let conn = &data.conn; - - let post: post::ActiveModel = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .unwrap() - .unwrap() - .into(); - - post.delete(conn).await.unwrap(); - - let flash = FlashData { - kind: "success".to_owned(), - message: "Post successfully deleted.".to_owned(), - }; - - actix_flash::Response::with_redirect(flash, "/") -} - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - let mut listenfd = ListenFd::from_env(); - let mut server = HttpServer::new(move || { - App::new() - .data(state.clone()) - .wrap(middleware::Logger::default()) // enable logger - .wrap(actix_flash::Flash::default()) - .configure(init) - .service(fs::Files::new("/static", "./static").show_files_listing()) - }); - - server = match listenfd.take_tcp_listener(0)? { - Some(listener) => server.listen(listener)?, - None => server.bind(&server_url)?, - }; - - println!("Starting server at {}", server_url); - server.run().await?; - - Ok(()) -} - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service(list); - cfg.service(new); - cfg.service(create); - cfg.service(edit); - cfg.service(update); - cfg.service(delete); +fn main() { + actix3_example_api::main(); } diff --git a/examples/actix_example/Cargo.toml b/examples/actix_example/Cargo.toml index 8be0a188a..7dc877c2c 100644 --- a/examples/actix_example/Cargo.toml +++ b/examples/actix_example/Cargo.toml @@ -6,30 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -actix-files = "0.6" -actix-http = "3" -actix-rt = "2.7" -actix-service = "2" -actix-web = "4" - -tera = "1.15.0" -dotenv = "0.15" -listenfd = "0.5" -serde = "1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-actix-native-tls", - "sqlx-mysql", - # "sqlx-postgres", - # "sqlx-sqlite", -] +actix-example-api = { path = "api" } diff --git a/examples/actix_example/README.md b/examples/actix_example/README.md index a2acf4140..d844b96a2 100644 --- a/examples/actix_example/README.md +++ b/examples/actix_example/README.md @@ -4,7 +4,7 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-mysql",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-mysql",` line) 1. Execute `cargo run` to start the server @@ -16,3 +16,10 @@ Run server with auto-reloading: cargo install systemfd cargo-watch systemfd --no-pid -s http::8000 -- cargo watch -x run ``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/actix_example/api/Cargo.toml b/examples/actix_example/api/Cargo.toml new file mode 100644 index 000000000..078e5561a --- /dev/null +++ b/examples/actix_example/api/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "actix-example-api" +version = "0.1.0" +authors = ["Sam Samai "] +edition = "2021" +publish = false + +[dependencies] +actix-example-core = { path = "../core" } +actix-files = "0.6" +actix-http = "3" +actix-rt = "2.7" +actix-service = "2" +actix-web = "4" +tera = "1.15.0" +dotenv = "0.15" +listenfd = "0.5" +serde = "1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/actix_example/api/src/lib.rs b/examples/actix_example/api/src/lib.rs new file mode 100644 index 000000000..f95200e84 --- /dev/null +++ b/examples/actix_example/api/src/lib.rs @@ -0,0 +1,215 @@ +use actix_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; +use actix_files::Files as Fs; +use actix_web::{ + error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, +}; + +use entity::post; +use listenfd::ListenFd; +use migration::{Migrator, MigratorTrait}; +use serde::{Deserialize, Serialize}; +use std::env; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} + +#[derive(Debug, Deserialize)] +pub struct Params { + page: Option, + posts_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +#[get("/")] +async fn list(req: HttpRequest, data: web::Data) -> Result { + let template = &data.templates; + let conn = &data.conn; + + // get params + let params = web::Query::::from_query(req.query_string()).unwrap(); + + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = Query::find_posts_in_page(conn, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + let body = template + .render("index.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[get("/new")] +async fn new(data: web::Data) -> Result { + let template = &data.templates; + let ctx = tera::Context::new(); + let body = template + .render("new.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/")] +async fn create( + data: web::Data, + post_form: web::Form, +) -> Result { + let conn = &data.conn; + + let form = post_form.into_inner(); + + Mutation::create_post(conn, form) + .await + .expect("could not insert post"); + + Ok(HttpResponse::Found() + .append_header(("location", "/")) + .finish()) +} + +#[get("/{id}")] +async fn edit(data: web::Data, id: web::Path) -> Result { + let conn = &data.conn; + let template = &data.templates; + let id = id.into_inner(); + + let post: post::Model = Query::find_post_by_id(conn, id) + .await + .expect("could not find post") + .unwrap_or_else(|| panic!("could not find post with id {}", id)); + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = template + .render("edit.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/{id}")] +async fn update( + data: web::Data, + id: web::Path, + post_form: web::Form, +) -> Result { + let conn = &data.conn; + let form = post_form.into_inner(); + let id = id.into_inner(); + + Mutation::update_post_by_id(conn, id, form) + .await + .expect("could not edit post"); + + Ok(HttpResponse::Found() + .append_header(("location", "/")) + .finish()) +} + +#[post("/delete/{id}")] +async fn delete(data: web::Data, id: web::Path) -> Result { + let conn = &data.conn; + let id = id.into_inner(); + + Mutation::delete_post(conn, id) + .await + .expect("could not delete post"); + + Ok(HttpResponse::Found() + .append_header(("location", "/")) + .finish()) +} + +async fn not_found(data: web::Data, request: HttpRequest) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("uri", request.uri().path()); + + let template = &data.templates; + let body = template + .render("error/404.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[actix_web::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // establish connection to database and apply migrations + // -> create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + + // load tera templates and build app state + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + // create server and try to serve over socket if possible + let mut listenfd = ListenFd::from_env(); + let mut server = HttpServer::new(move || { + App::new() + .service(Fs::new("/static", "./api/static")) + .app_data(web::Data::new(state.clone())) + .wrap(middleware::Logger::default()) // enable logger + .default_service(web::route().to(not_found)) + .configure(init) + }); + + server = match listenfd.take_tcp_listener(0)? { + Some(listener) => server.listen(listener)?, + None => server.bind(&server_url)?, + }; + + println!("Starting server at {}", server_url); + server.run().await?; + + Ok(()) +} + +fn init(cfg: &mut web::ServiceConfig) { + cfg.service(list); + cfg.service(new); + cfg.service(create); + cfg.service(edit); + cfg.service(update); + cfg.service(delete); +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/actix_example/static/css/normalize.css b/examples/actix_example/api/static/css/normalize.css similarity index 100% rename from examples/actix_example/static/css/normalize.css rename to examples/actix_example/api/static/css/normalize.css diff --git a/examples/actix_example/static/css/skeleton.css b/examples/actix_example/api/static/css/skeleton.css similarity index 100% rename from examples/actix_example/static/css/skeleton.css rename to examples/actix_example/api/static/css/skeleton.css diff --git a/examples/actix_example/static/css/style.css b/examples/actix_example/api/static/css/style.css similarity index 100% rename from examples/actix_example/static/css/style.css rename to examples/actix_example/api/static/css/style.css diff --git a/examples/actix_example/static/images/favicon.png b/examples/actix_example/api/static/images/favicon.png similarity index 100% rename from examples/actix_example/static/images/favicon.png rename to examples/actix_example/api/static/images/favicon.png diff --git a/examples/actix_example/templates/edit.html.tera b/examples/actix_example/api/templates/edit.html.tera similarity index 100% rename from examples/actix_example/templates/edit.html.tera rename to examples/actix_example/api/templates/edit.html.tera diff --git a/examples/actix_example/templates/error/404.html.tera b/examples/actix_example/api/templates/error/404.html.tera similarity index 100% rename from examples/actix_example/templates/error/404.html.tera rename to examples/actix_example/api/templates/error/404.html.tera diff --git a/examples/actix_example/templates/index.html.tera b/examples/actix_example/api/templates/index.html.tera similarity index 100% rename from examples/actix_example/templates/index.html.tera rename to examples/actix_example/api/templates/index.html.tera diff --git a/examples/actix_example/templates/layout.html.tera b/examples/actix_example/api/templates/layout.html.tera similarity index 100% rename from examples/actix_example/templates/layout.html.tera rename to examples/actix_example/api/templates/layout.html.tera diff --git a/examples/actix_example/templates/new.html.tera b/examples/actix_example/api/templates/new.html.tera similarity index 100% rename from examples/actix_example/templates/new.html.tera rename to examples/actix_example/api/templates/new.html.tera diff --git a/examples/actix_example/core/Cargo.toml b/examples/actix_example/core/Cargo.toml new file mode 100644 index 000000000..2044b6f22 --- /dev/null +++ b/examples/actix_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "actix-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + "sqlx-mysql", + # "sqlx-postgres", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/actix_example/core/src/lib.rs b/examples/actix_example/core/src/lib.rs new file mode 100644 index 000000000..4a80f2391 --- /dev/null +++ b/examples/actix_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/actix_example/core/src/mutation.rs b/examples/actix_example/core/src/mutation.rs new file mode 100644 index 000000000..dd6891d4a --- /dev/null +++ b/examples/actix_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/actix_example/core/src/query.rs b/examples/actix_example/core/src/query.rs new file mode 100644 index 000000000..e8d2668f5 --- /dev/null +++ b/examples/actix_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/actix_example/core/tests/mock.rs b/examples/actix_example/core/tests/mock.rs new file mode 100644 index 000000000..76531b671 --- /dev/null +++ b/examples/actix_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use actix_example_core::{Mutation, Query}; +use entity::post; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/actix_example/core/tests/prepare.rs b/examples/actix_example/core/tests/prepare.rs new file mode 100644 index 000000000..451804937 --- /dev/null +++ b/examples/actix_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/actix_example/src/main.rs b/examples/actix_example/src/main.rs index fa55c507a..a9cdecdcf 100644 --- a/examples/actix_example/src/main.rs +++ b/examples/actix_example/src/main.rs @@ -1,223 +1,3 @@ -use actix_files::Files as Fs; -use actix_web::{ - error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, -}; - -use entity::post; -use entity::post::Entity as Post; -use listenfd::ListenFd; -use migration::{Migrator, MigratorTrait}; -use sea_orm::DatabaseConnection; -use sea_orm::{entity::*, query::*}; -use serde::{Deserialize, Serialize}; -use std::env; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} - -#[derive(Debug, Deserialize)] -pub struct Params { - page: Option, - posts_per_page: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct FlashData { - kind: String, - message: String, -} - -#[get("/")] -async fn list(req: HttpRequest, data: web::Data) -> Result { - let template = &data.templates; - let conn = &data.conn; - - // get params - let params = web::Query::::from_query(req.query_string()).unwrap(); - - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(conn, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - let body = template - .render("index.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[get("/new")] -async fn new(data: web::Data) -> Result { - let template = &data.templates; - let ctx = tera::Context::new(); - let body = template - .render("new.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/")] -async fn create( - data: web::Data, - post_form: web::Form, -) -> Result { - let conn = &data.conn; - - let form = post_form.into_inner(); - - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(conn) - .await - .expect("could not insert post"); - - Ok(HttpResponse::Found() - .append_header(("location", "/")) - .finish()) -} - -#[get("/{id}")] -async fn edit(data: web::Data, id: web::Path) -> Result { - let conn = &data.conn; - let template = &data.templates; - - let post: post::Model = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .expect("could not find post") - .unwrap(); - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = template - .render("edit.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/{id}")] -async fn update( - data: web::Data, - id: web::Path, - post_form: web::Form, -) -> Result { - let conn = &data.conn; - let form = post_form.into_inner(); - - post::ActiveModel { - id: Set(id.into_inner()), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(conn) - .await - .expect("could not edit post"); - - Ok(HttpResponse::Found() - .append_header(("location", "/")) - .finish()) -} - -#[post("/delete/{id}")] -async fn delete(data: web::Data, id: web::Path) -> Result { - let conn = &data.conn; - - let post: post::ActiveModel = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .unwrap() - .unwrap() - .into(); - - post.delete(conn).await.unwrap(); - - Ok(HttpResponse::Found() - .append_header(("location", "/")) - .finish()) -} - -async fn not_found(data: web::Data, request: HttpRequest) -> Result { - let mut ctx = tera::Context::new(); - ctx.insert("uri", request.uri().path()); - - let template = &data.templates; - let body = template - .render("error/404.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // establish connection to database and apply migrations - // -> create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - - // load tera templates and build app state - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - // create server and try to serve over socket if possible - let mut listenfd = ListenFd::from_env(); - let mut server = HttpServer::new(move || { - App::new() - .service(Fs::new("/static", "./static")) - .app_data(web::Data::new(state.clone())) - .wrap(middleware::Logger::default()) // enable logger - .default_service(web::route().to(not_found)) - .configure(init) - }); - - server = match listenfd.take_tcp_listener(0)? { - Some(listener) => server.listen(listener)?, - None => server.bind(&server_url)?, - }; - - println!("Starting server at {}", server_url); - server.run().await?; - - Ok(()) -} - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service(list); - cfg.service(new); - cfg.service(create); - cfg.service(edit); - cfg.service(update); - cfg.service(delete); +fn main() { + actix_example_api::main(); } diff --git a/examples/axum_example/Cargo.toml b/examples/axum_example/Cargo.toml index 43478ad0f..0387b657b 100644 --- a/examples/axum_example/Cargo.toml +++ b/examples/axum_example/Cargo.toml @@ -5,32 +5,8 @@ authors = ["Yoshiera Huang "] edition = "2021" publish = false -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.18.1", features = ["full"] } -axum = "0.5.4" -tower = "0.4.12" -tower-http = { version = "0.3.3", features = ["fs"] } -tower-cookies = "0.6.0" -anyhow = "1.0.57" -dotenv = "0.15.0" -serde = "1.0.137" -serde_json = "1.0.81" -tera = "1.15.0" -tracing-subscriber = { version = "0.3.11", features = ["env-filter"] } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-postgres", - # "sqlx-mysql", - # "sqlx-sqlite", -] +axum-example-api = { path = "api" } diff --git a/examples/axum_example/README.md b/examples/axum_example/README.md index a3c624225..fe120e715 100644 --- a/examples/axum_example/README.md +++ b/examples/axum_example/README.md @@ -4,8 +4,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-postgres",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-postgres",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/axum_example/api/Cargo.toml b/examples/axum_example/api/Cargo.toml new file mode 100644 index 000000000..dc1f5ce49 --- /dev/null +++ b/examples/axum_example/api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "axum-example-api" +version = "0.1.0" +authors = ["Yoshiera Huang "] +edition = "2021" +publish = false + +[dependencies] +axum-example-core = { path = "../core" } +tokio = { version = "1.18.1", features = ["full"] } +axum = "0.5.4" +tower = "0.4.12" +tower-http = { version = "0.3.3", features = ["fs"] } +tower-cookies = "0.6.0" +anyhow = "1.0.57" +dotenv = "0.15.0" +serde = "1.0.137" +serde_json = "1.0.81" +tera = "1.15.0" +tracing-subscriber = { version = "0.3.11", features = ["env-filter"] } +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/axum_example/src/flash.rs b/examples/axum_example/api/src/flash.rs similarity index 100% rename from examples/axum_example/src/flash.rs rename to examples/axum_example/api/src/flash.rs diff --git a/examples/axum_example/api/src/lib.rs b/examples/axum_example/api/src/lib.rs new file mode 100644 index 000000000..ead63e7b1 --- /dev/null +++ b/examples/axum_example/api/src/lib.rs @@ -0,0 +1,210 @@ +mod flash; + +use axum::{ + extract::{Extension, Form, Path, Query}, + http::StatusCode, + response::Html, + routing::{get, get_service, post}, + Router, Server, +}; +use axum_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation as MutationCore, Query as QueryCore, +}; +use entity::post; +use flash::{get_flash_cookie, post_response, PostResponse}; +use migration::{Migrator, MigratorTrait}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use std::{env, net::SocketAddr}; +use tera::Tera; +use tower::ServiceBuilder; +use tower_cookies::{CookieManagerLayer, Cookies}; +use tower_http::services::ServeDir; + +#[tokio::main] +async fn start() -> anyhow::Result<()> { + env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + let conn = Database::connect(db_url) + .await + .expect("Database connection failed"); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")) + .expect("Tera initialization failed"); + // let state = AppState { templates, conn }; + + let app = Router::new() + .route("/", get(list_posts).post(create_post)) + .route("/:id", get(edit_post).post(update_post)) + .route("/new", get(new_post)) + .route("/delete/:id", post(delete_post)) + .nest( + "/static", + get_service(ServeDir::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/static" + ))) + .handle_error(|error: std::io::Error| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled internal error: {}", error), + ) + }), + ) + .layer( + ServiceBuilder::new() + .layer(CookieManagerLayer::new()) + .layer(Extension(conn)) + .layer(Extension(templates)), + ); + + let addr = SocketAddr::from_str(&server_url).unwrap(); + Server::bind(&addr).serve(app.into_make_service()).await?; + + Ok(()) +} + +#[derive(Deserialize)] +struct Params { + page: Option, + posts_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +async fn list_posts( + Extension(ref templates): Extension, + Extension(ref conn): Extension, + Query(params): Query, + cookies: Cookies, +) -> Result, (StatusCode, &'static str)> { + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(5); + + let (posts, num_pages) = QueryCore::find_posts_in_page(conn, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + if let Some(value) = get_flash_cookie::(&cookies) { + ctx.insert("flash", &value); + } + + let body = templates + .render("index.html.tera", &ctx) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; + + Ok(Html(body)) +} + +async fn new_post( + Extension(ref templates): Extension, +) -> Result, (StatusCode, &'static str)> { + let ctx = tera::Context::new(); + let body = templates + .render("new.html.tera", &ctx) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; + + Ok(Html(body)) +} + +async fn create_post( + Extension(ref conn): Extension, + form: Form, + mut cookies: Cookies, +) -> Result { + let form = form.0; + + MutationCore::create_post(conn, form) + .await + .expect("could not insert post"); + + let data = FlashData { + kind: "success".to_owned(), + message: "Post succcessfully added".to_owned(), + }; + + Ok(post_response(&mut cookies, data)) +} + +async fn edit_post( + Extension(ref templates): Extension, + Extension(ref conn): Extension, + Path(id): Path, +) -> Result, (StatusCode, &'static str)> { + let post: post::Model = QueryCore::find_post_by_id(conn, id) + .await + .expect("could not find post") + .unwrap_or_else(|| panic!("could not find post with id {}", id)); + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = templates + .render("edit.html.tera", &ctx) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; + + Ok(Html(body)) +} + +async fn update_post( + Extension(ref conn): Extension, + Path(id): Path, + form: Form, + mut cookies: Cookies, +) -> Result { + let form = form.0; + + MutationCore::update_post_by_id(conn, id, form) + .await + .expect("could not edit post"); + + let data = FlashData { + kind: "success".to_owned(), + message: "Post succcessfully updated".to_owned(), + }; + + Ok(post_response(&mut cookies, data)) +} + +async fn delete_post( + Extension(ref conn): Extension, + Path(id): Path, + mut cookies: Cookies, +) -> Result { + MutationCore::delete_post(conn, id) + .await + .expect("could not delete post"); + + let data = FlashData { + kind: "success".to_owned(), + message: "Post succcessfully deleted".to_owned(), + }; + + Ok(post_response(&mut cookies, data)) +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/axum_example/static/css/normalize.css b/examples/axum_example/api/static/css/normalize.css similarity index 100% rename from examples/axum_example/static/css/normalize.css rename to examples/axum_example/api/static/css/normalize.css diff --git a/examples/axum_example/static/css/skeleton.css b/examples/axum_example/api/static/css/skeleton.css similarity index 100% rename from examples/axum_example/static/css/skeleton.css rename to examples/axum_example/api/static/css/skeleton.css diff --git a/examples/axum_example/static/css/style.css b/examples/axum_example/api/static/css/style.css similarity index 100% rename from examples/axum_example/static/css/style.css rename to examples/axum_example/api/static/css/style.css diff --git a/examples/axum_example/static/images/favicon.png b/examples/axum_example/api/static/images/favicon.png similarity index 100% rename from examples/axum_example/static/images/favicon.png rename to examples/axum_example/api/static/images/favicon.png diff --git a/examples/axum_example/templates/edit.html.tera b/examples/axum_example/api/templates/edit.html.tera similarity index 100% rename from examples/axum_example/templates/edit.html.tera rename to examples/axum_example/api/templates/edit.html.tera diff --git a/examples/axum_example/templates/error/404.html.tera b/examples/axum_example/api/templates/error/404.html.tera similarity index 100% rename from examples/axum_example/templates/error/404.html.tera rename to examples/axum_example/api/templates/error/404.html.tera diff --git a/examples/axum_example/templates/index.html.tera b/examples/axum_example/api/templates/index.html.tera similarity index 100% rename from examples/axum_example/templates/index.html.tera rename to examples/axum_example/api/templates/index.html.tera diff --git a/examples/axum_example/templates/layout.html.tera b/examples/axum_example/api/templates/layout.html.tera similarity index 100% rename from examples/axum_example/templates/layout.html.tera rename to examples/axum_example/api/templates/layout.html.tera diff --git a/examples/axum_example/templates/new.html.tera b/examples/axum_example/api/templates/new.html.tera similarity index 100% rename from examples/axum_example/templates/new.html.tera rename to examples/axum_example/api/templates/new.html.tera diff --git a/examples/axum_example/core/Cargo.toml b/examples/axum_example/core/Cargo.toml new file mode 100644 index 000000000..2ba788749 --- /dev/null +++ b/examples/axum_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "axum-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + "sqlx-postgres", + # "sqlx-mysql", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/axum_example/core/src/lib.rs b/examples/axum_example/core/src/lib.rs new file mode 100644 index 000000000..4a80f2391 --- /dev/null +++ b/examples/axum_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/axum_example/core/src/mutation.rs b/examples/axum_example/core/src/mutation.rs new file mode 100644 index 000000000..dd6891d4a --- /dev/null +++ b/examples/axum_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/axum_example/core/src/query.rs b/examples/axum_example/core/src/query.rs new file mode 100644 index 000000000..e8d2668f5 --- /dev/null +++ b/examples/axum_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/axum_example/core/tests/mock.rs b/examples/axum_example/core/tests/mock.rs new file mode 100644 index 000000000..832105302 --- /dev/null +++ b/examples/axum_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use axum_example_core::{Mutation, Query}; +use entity::post; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/axum_example/core/tests/prepare.rs b/examples/axum_example/core/tests/prepare.rs new file mode 100644 index 000000000..451804937 --- /dev/null +++ b/examples/axum_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/axum_example/src/main.rs b/examples/axum_example/src/main.rs index 3669faca2..e0b58d2ec 100644 --- a/examples/axum_example/src/main.rs +++ b/examples/axum_example/src/main.rs @@ -1,220 +1,3 @@ -mod flash; - -use axum::{ - extract::{Extension, Form, Path, Query}, - http::StatusCode, - response::Html, - routing::{get, get_service, post}, - Router, Server, -}; -use entity::post; -use flash::{get_flash_cookie, post_response, PostResponse}; -use migration::{Migrator, MigratorTrait}; -use post::Entity as Post; -use sea_orm::{prelude::*, Database, QueryOrder, Set}; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; -use std::{env, net::SocketAddr}; -use tera::Tera; -use tower::ServiceBuilder; -use tower_cookies::{CookieManagerLayer, Cookies}; -use tower_http::services::ServeDir; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - let conn = Database::connect(db_url) - .await - .expect("Database connection failed"); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")) - .expect("Tera initialization failed"); - // let state = AppState { templates, conn }; - - let app = Router::new() - .route("/", get(list_posts).post(create_post)) - .route("/:id", get(edit_post).post(update_post)) - .route("/new", get(new_post)) - .route("/delete/:id", post(delete_post)) - .nest( - "/static", - get_service(ServeDir::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/static" - ))) - .handle_error(|error: std::io::Error| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled internal error: {}", error), - ) - }), - ) - .layer( - ServiceBuilder::new() - .layer(CookieManagerLayer::new()) - .layer(Extension(conn)) - .layer(Extension(templates)), - ); - - let addr = SocketAddr::from_str(&server_url).unwrap(); - Server::bind(&addr).serve(app.into_make_service()).await?; - - Ok(()) -} - -#[derive(Deserialize)] -struct Params { - page: Option, - posts_per_page: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct FlashData { - kind: String, - message: String, -} - -async fn list_posts( - Extension(ref templates): Extension, - Extension(ref conn): Extension, - Query(params): Query, - cookies: Cookies, -) -> Result, (StatusCode, &'static str)> { - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(5); - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(conn, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - if let Some(value) = get_flash_cookie::(&cookies) { - ctx.insert("flash", &value); - } - - let body = templates - .render("index.html.tera", &ctx) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; - - Ok(Html(body)) -} - -async fn new_post( - Extension(ref templates): Extension, -) -> Result, (StatusCode, &'static str)> { - let ctx = tera::Context::new(); - let body = templates - .render("new.html.tera", &ctx) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; - - Ok(Html(body)) -} - -async fn create_post( - Extension(ref conn): Extension, - form: Form, - mut cookies: Cookies, -) -> Result { - let model = form.0; - - post::ActiveModel { - title: Set(model.title.to_owned()), - text: Set(model.text.to_owned()), - ..Default::default() - } - .save(conn) - .await - .expect("could not insert post"); - - let data = FlashData { - kind: "success".to_owned(), - message: "Post succcessfully added".to_owned(), - }; - - Ok(post_response(&mut cookies, data)) -} - -async fn edit_post( - Extension(ref templates): Extension, - Extension(ref conn): Extension, - Path(id): Path, -) -> Result, (StatusCode, &'static str)> { - let post: post::Model = Post::find_by_id(id) - .one(conn) - .await - .expect("could not find post") - .unwrap(); - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = templates - .render("edit.html.tera", &ctx) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; - - Ok(Html(body)) -} - -async fn update_post( - Extension(ref conn): Extension, - Path(id): Path, - form: Form, - mut cookies: Cookies, -) -> Result { - let model = form.0; - - post::ActiveModel { - id: Set(id), - title: Set(model.title.to_owned()), - text: Set(model.text.to_owned()), - } - .save(conn) - .await - .expect("could not edit post"); - - let data = FlashData { - kind: "success".to_owned(), - message: "Post succcessfully updated".to_owned(), - }; - - Ok(post_response(&mut cookies, data)) -} - -async fn delete_post( - Extension(ref conn): Extension, - Path(id): Path, - mut cookies: Cookies, -) -> Result { - let post: post::ActiveModel = Post::find_by_id(id) - .one(conn) - .await - .unwrap() - .unwrap() - .into(); - - post.delete(conn).await.unwrap(); - - let data = FlashData { - kind: "success".to_owned(), - message: "Post succcessfully deleted".to_owned(), - }; - - Ok(post_response(&mut cookies, data)) +fn main() { + axum_example_api::main(); } diff --git a/examples/graphql_example/Cargo.toml b/examples/graphql_example/Cargo.toml index 107de341c..c3e342dc3 100644 --- a/examples/graphql_example/Cargo.toml +++ b/examples/graphql_example/Cargo.toml @@ -7,22 +7,7 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.0", features = ["full"] } -axum = "^0.5.1" -dotenv = "0.15.0" -async-graphql-axum = "^4.0.6" -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "runtime-tokio-native-tls", - # "sqlx-postgres", - # "sqlx-mysql", - "sqlx-sqlite" -] +graphql-example-api = { path = "api" } diff --git a/examples/graphql_example/README.md b/examples/graphql_example/README.md index 88501958d..351c7df55 100644 --- a/examples/graphql_example/README.md +++ b/examples/graphql_example/README.md @@ -6,8 +6,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:3000/api/graphql](http://localhost:3000/api/graphql) in browser + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/graphql_example/api/Cargo.toml b/examples/graphql_example/api/Cargo.toml new file mode 100644 index 000000000..e4526410b --- /dev/null +++ b/examples/graphql_example/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "graphql-example-api" +authors = ["Aaron Leopold "] +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +graphql-example-core = { path = "../core" } +tokio = { version = "1.0", features = ["full"] } +axum = "^0.5.1" +dotenv = "0.15.0" +async-graphql-axum = "^4.0.6" +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/graphql_example/api/src/db.rs b/examples/graphql_example/api/src/db.rs new file mode 100644 index 000000000..a1b79cd41 --- /dev/null +++ b/examples/graphql_example/api/src/db.rs @@ -0,0 +1,21 @@ +use graphql_example_core::sea_orm::DatabaseConnection; + +pub struct Database { + pub connection: DatabaseConnection, +} + +impl Database { + pub async fn new() -> Self { + let connection = graphql_example_core::sea_orm::Database::connect( + std::env::var("DATABASE_URL").unwrap(), + ) + .await + .expect("Could not connect to database"); + + Database { connection } + } + + pub fn get_connection(&self) -> &DatabaseConnection { + &self.connection + } +} diff --git a/examples/graphql_example/src/graphql/mod.rs b/examples/graphql_example/api/src/graphql/mod.rs similarity index 100% rename from examples/graphql_example/src/graphql/mod.rs rename to examples/graphql_example/api/src/graphql/mod.rs diff --git a/examples/graphql_example/src/graphql/mutation/mod.rs b/examples/graphql_example/api/src/graphql/mutation/mod.rs similarity index 100% rename from examples/graphql_example/src/graphql/mutation/mod.rs rename to examples/graphql_example/api/src/graphql/mutation/mod.rs diff --git a/examples/graphql_example/src/graphql/mutation/note.rs b/examples/graphql_example/api/src/graphql/mutation/note.rs similarity index 68% rename from examples/graphql_example/src/graphql/mutation/note.rs rename to examples/graphql_example/api/src/graphql/mutation/note.rs index 600b462b8..a3ce9f48f 100644 --- a/examples/graphql_example/src/graphql/mutation/note.rs +++ b/examples/graphql_example/api/src/graphql/mutation/note.rs @@ -1,7 +1,7 @@ use async_graphql::{Context, Object, Result}; use entity::async_graphql::{self, InputObject, SimpleObject}; use entity::note; -use sea_orm::{ActiveModelTrait, Set}; +use graphql_example_core::Mutation; use crate::db::Database; @@ -14,6 +14,16 @@ pub struct CreateNoteInput { pub text: String, } +impl CreateNoteInput { + fn into_model_with_arbitrary_id(self) -> note::Model { + note::Model { + id: 0, + title: self.title, + text: self.text, + } + } +} + #[derive(SimpleObject)] pub struct DeleteResult { pub success: bool, @@ -31,22 +41,18 @@ impl NoteMutation { input: CreateNoteInput, ) -> Result { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - let note = note::ActiveModel { - title: Set(input.title), - text: Set(input.text), - ..Default::default() - }; - - Ok(note.insert(db.get_connection()).await?) + Ok(Mutation::create_note(conn, input.into_model_with_arbitrary_id()).await?) } pub async fn delete_note(&self, ctx: &Context<'_>, id: i32) -> Result { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - let res = note::Entity::delete_by_id(id) - .exec(db.get_connection()) - .await?; + let res = Mutation::delete_note(conn, id) + .await + .expect("Cannot delete note"); if res.rows_affected <= 1 { Ok(DeleteResult { diff --git a/examples/graphql_example/src/graphql/query/mod.rs b/examples/graphql_example/api/src/graphql/query/mod.rs similarity index 100% rename from examples/graphql_example/src/graphql/query/mod.rs rename to examples/graphql_example/api/src/graphql/query/mod.rs diff --git a/examples/graphql_example/src/graphql/query/note.rs b/examples/graphql_example/api/src/graphql/query/note.rs similarity index 75% rename from examples/graphql_example/src/graphql/query/note.rs rename to examples/graphql_example/api/src/graphql/query/note.rs index 696d47206..1ac2549d9 100644 --- a/examples/graphql_example/src/graphql/query/note.rs +++ b/examples/graphql_example/api/src/graphql/query/note.rs @@ -1,6 +1,6 @@ use async_graphql::{Context, Object, Result}; use entity::{async_graphql, note}; -use sea_orm::EntityTrait; +use graphql_example_core::Query; use crate::db::Database; @@ -11,18 +11,18 @@ pub struct NoteQuery; impl NoteQuery { async fn get_notes(&self, ctx: &Context<'_>) -> Result> { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - Ok(note::Entity::find() - .all(db.get_connection()) + Ok(Query::get_all_notes(conn) .await .map_err(|e| e.to_string())?) } async fn get_note_by_id(&self, ctx: &Context<'_>, id: i32) -> Result> { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - Ok(note::Entity::find_by_id(id) - .one(db.get_connection()) + Ok(Query::find_note_by_id(conn, id) .await .map_err(|e| e.to_string())?) } diff --git a/examples/graphql_example/src/graphql/schema.rs b/examples/graphql_example/api/src/graphql/schema.rs similarity index 100% rename from examples/graphql_example/src/graphql/schema.rs rename to examples/graphql_example/api/src/graphql/schema.rs diff --git a/examples/graphql_example/api/src/lib.rs b/examples/graphql_example/api/src/lib.rs new file mode 100644 index 000000000..42e63e1cf --- /dev/null +++ b/examples/graphql_example/api/src/lib.rs @@ -0,0 +1,49 @@ +mod db; +mod graphql; + +use entity::async_graphql; + +use async_graphql::http::{playground_source, GraphQLPlaygroundConfig}; +use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; +use axum::{ + extract::Extension, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use graphql::schema::{build_schema, AppSchema}; + +#[cfg(debug_assertions)] +use dotenv::dotenv; + +async fn graphql_handler(schema: Extension, req: GraphQLRequest) -> GraphQLResponse { + schema.execute(req.into_inner()).await.into() +} + +async fn graphql_playground() -> impl IntoResponse { + Html(playground_source(GraphQLPlaygroundConfig::new( + "/api/graphql", + ))) +} + +#[tokio::main] +pub async fn main() { + #[cfg(debug_assertions)] + dotenv().ok(); + + let schema = build_schema().await; + + let app = Router::new() + .route( + "/api/graphql", + get(graphql_playground).post(graphql_handler), + ) + .layer(Extension(schema)); + + println!("Playground: http://localhost:3000/api/graphql"); + + axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); +} diff --git a/examples/graphql_example/core/Cargo.toml b/examples/graphql_example/core/Cargo.toml new file mode 100644 index 000000000..739d4b38f --- /dev/null +++ b/examples/graphql_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "graphql-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + # "sqlx-postgres", + # "sqlx-mysql", + "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/graphql_example/core/src/lib.rs b/examples/graphql_example/core/src/lib.rs new file mode 100644 index 000000000..4a80f2391 --- /dev/null +++ b/examples/graphql_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/graphql_example/core/src/mutation.rs b/examples/graphql_example/core/src/mutation.rs new file mode 100644 index 000000000..1f2447fba --- /dev/null +++ b/examples/graphql_example/core/src/mutation.rs @@ -0,0 +1,54 @@ +use ::entity::{note, note::Entity as Note}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_note(db: &DbConn, form_data: note::Model) -> Result { + let active_model = note::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + }; + let res = Note::insert(active_model).exec(db).await?; + + Ok(note::Model { + id: res.last_insert_id, + ..form_data + }) + } + + pub async fn update_note_by_id( + db: &DbConn, + id: i32, + form_data: note::Model, + ) -> Result { + let note: note::ActiveModel = Note::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find note.".to_owned())) + .map(Into::into)?; + + note::ActiveModel { + id: note.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_note(db: &DbConn, id: i32) -> Result { + let note: note::ActiveModel = Note::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find note.".to_owned())) + .map(Into::into)?; + + note.delete(db).await + } + + pub async fn delete_all_notes(db: &DbConn) -> Result { + Note::delete_many().exec(db).await + } +} diff --git a/examples/graphql_example/core/src/query.rs b/examples/graphql_example/core/src/query.rs new file mode 100644 index 000000000..7b6dc1b7d --- /dev/null +++ b/examples/graphql_example/core/src/query.rs @@ -0,0 +1,30 @@ +use ::entity::{note, note::Entity as Note}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_note_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Note::find_by_id(id).one(db).await + } + + pub async fn get_all_notes(db: &DbConn) -> Result, DbErr> { + Note::find().all(db).await + } + + /// If ok, returns (note models, num pages). + pub async fn find_notes_in_page( + db: &DbConn, + page: u64, + notes_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Note::find() + .order_by_asc(note::Column::Id) + .paginate(db, notes_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated notes + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/graphql_example/core/tests/mock.rs b/examples/graphql_example/core/tests/mock.rs new file mode 100644 index 000000000..16a56189f --- /dev/null +++ b/examples/graphql_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::note; +use graphql_example_core::{Mutation, Query}; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let note = Query::find_note_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(note.id, 1); + } + + { + let note = Query::find_note_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(note.id, 5); + } + + { + let note = Mutation::create_note( + db, + note::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + note, + note::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + } + ); + } + + { + let note = Mutation::update_note_by_id( + db, + 1, + note::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + note, + note::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_note(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_notes(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/graphql_example/core/tests/prepare.rs b/examples/graphql_example/core/tests/prepare.rs new file mode 100644 index 000000000..fd55936d6 --- /dev/null +++ b/examples/graphql_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::note; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![note::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![note::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![note::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![note::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![note::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![note::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/graphql_example/src/db.rs b/examples/graphql_example/src/db.rs deleted file mode 100644 index 3cc41e27b..000000000 --- a/examples/graphql_example/src/db.rs +++ /dev/null @@ -1,19 +0,0 @@ -use sea_orm::DatabaseConnection; - -pub struct Database { - pub connection: DatabaseConnection, -} - -impl Database { - pub async fn new() -> Self { - let connection = sea_orm::Database::connect(std::env::var("DATABASE_URL").unwrap()) - .await - .expect("Could not connect to database"); - - Database { connection } - } - - pub fn get_connection(&self) -> &DatabaseConnection { - &self.connection - } -} diff --git a/examples/graphql_example/src/main.rs b/examples/graphql_example/src/main.rs index 665f79f23..308b656a4 100644 --- a/examples/graphql_example/src/main.rs +++ b/examples/graphql_example/src/main.rs @@ -1,49 +1,3 @@ -mod db; -mod graphql; - -use entity::async_graphql; - -use async_graphql::http::{playground_source, GraphQLPlaygroundConfig}; -use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; -use axum::{ - extract::Extension, - response::{Html, IntoResponse}, - routing::get, - Router, -}; -use graphql::schema::{build_schema, AppSchema}; - -#[cfg(debug_assertions)] -use dotenv::dotenv; - -async fn graphql_handler(schema: Extension, req: GraphQLRequest) -> GraphQLResponse { - schema.execute(req.into_inner()).await.into() -} - -async fn graphql_playground() -> impl IntoResponse { - Html(playground_source(GraphQLPlaygroundConfig::new( - "/api/graphql", - ))) -} - -#[tokio::main] -async fn main() { - #[cfg(debug_assertions)] - dotenv().ok(); - - let schema = build_schema().await; - - let app = Router::new() - .route( - "/api/graphql", - get(graphql_playground).post(graphql_handler), - ) - .layer(Extension(schema)); - - println!("Playground: http://localhost:3000/api/graphql"); - - axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) - .serve(app.into_make_service()) - .await - .unwrap(); +fn main() { + graphql_example_api::main(); } diff --git a/examples/jsonrpsee_example/Cargo.toml b/examples/jsonrpsee_example/Cargo.toml index d507a5c6b..7365aa551 100644 --- a/examples/jsonrpsee_example/Cargo.toml +++ b/examples/jsonrpsee_example/Cargo.toml @@ -6,28 +6,7 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -jsonrpsee = { version = "^0.8.0", features = ["full"] } -jsonrpsee-core = "0.9.0" -tokio = { version = "1.8.0", features = ["full"] } -serde = { version = "1", features = ["derive"] } -dotenv = "0.15" -entity = { path = "entity" } -migration = { path = "migration" } -anyhow = "1.0.52" -async-trait = "0.1.52" -log = { version = "0.4", features = ["std"] } -simplelog = "*" - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-sqlite", - # "sqlx-postgres", - # "sqlx-mysql", -] +jsonrpsee-example-api = { path = "api" } diff --git a/examples/jsonrpsee_example/README.md b/examples/jsonrpsee_example/README.md index 804893319..4d45e5b64 100644 --- a/examples/jsonrpsee_example/README.md +++ b/examples/jsonrpsee_example/README.md @@ -2,11 +2,11 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server -2. Send jsonrpc request to server +1. Send jsonrpc request to server ```shell #insert @@ -20,7 +20,7 @@ curl --location --request POST 'http://127.0.0.1:8000' \ } ], "id": 2}' -#list +#list curl --location --request POST 'http://127.0.0.1:8000' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -33,7 +33,7 @@ curl --location --request POST 'http://127.0.0.1:8000' \ "id": 2 }' -#delete +#delete curl --location --request POST 'http://127.0.0.1:8000' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -61,4 +61,11 @@ curl --location --request POST 'http://127.0.0.1:8000' \ "id": 2 }' -``` \ No newline at end of file +``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/jsonrpsee_example/api/Cargo.toml b/examples/jsonrpsee_example/api/Cargo.toml new file mode 100644 index 000000000..51c959e6a --- /dev/null +++ b/examples/jsonrpsee_example/api/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "jsonrpsee-example-api" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +jsonrpsee-example-core = { path = "../core" } +jsonrpsee = { version = "^0.8.0", features = ["full"] } +jsonrpsee-core = "0.9.0" +tokio = { version = "1.8.0", features = ["full"] } +serde = { version = "1", features = ["derive"] } +dotenv = "0.15" +entity = { path = "../entity" } +migration = { path = "../migration" } +anyhow = "1.0.52" +async-trait = "0.1.52" +log = { version = "0.4", features = ["std"] } +simplelog = "*" diff --git a/examples/jsonrpsee_example/api/src/lib.rs b/examples/jsonrpsee_example/api/src/lib.rs new file mode 100644 index 000000000..f98cc1832 --- /dev/null +++ b/examples/jsonrpsee_example/api/src/lib.rs @@ -0,0 +1,143 @@ +use std::env; + +use anyhow::anyhow; +use entity::post; +use jsonrpsee::core::{async_trait, RpcResult}; +use jsonrpsee::http_server::HttpServerBuilder; +use jsonrpsee::proc_macros::rpc; +use jsonrpsee::types::error::CallError; +use jsonrpsee_example_core::sea_orm::{Database, DatabaseConnection}; +use jsonrpsee_example_core::{Mutation, Query}; +use log::info; +use migration::{Migrator, MigratorTrait}; +use simplelog::*; +use std::fmt::Display; +use std::net::SocketAddr; +use tokio::signal::ctrl_c; +use tokio::signal::unix::{signal, SignalKind}; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[rpc(server, client)] +trait PostRpc { + #[method(name = "Post.List")] + async fn list( + &self, + page: Option, + posts_per_page: Option, + ) -> RpcResult>; + + #[method(name = "Post.Insert")] + async fn insert(&self, p: post::Model) -> RpcResult; + + #[method(name = "Post.Update")] + async fn update(&self, p: post::Model) -> RpcResult; + + #[method(name = "Post.Delete")] + async fn delete(&self, id: i32) -> RpcResult; +} + +struct PpcImpl { + conn: DatabaseConnection, +} + +#[async_trait] +impl PostRpcServer for PpcImpl { + async fn list( + &self, + page: Option, + posts_per_page: Option, + ) -> RpcResult> { + let page = page.unwrap_or(1); + let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + Query::find_posts_in_page(&self.conn, page, posts_per_page) + .await + .map(|(p, _)| p) + .internal_call_error() + } + + async fn insert(&self, p: post::Model) -> RpcResult { + let new_post = Mutation::create_post(&self.conn, p) + .await + .internal_call_error()?; + + Ok(new_post.id.unwrap()) + } + + async fn update(&self, p: post::Model) -> RpcResult { + Mutation::update_post_by_id(&self.conn, p.id, p) + .await + .map(|_| true) + .internal_call_error() + } + async fn delete(&self, id: i32) -> RpcResult { + Mutation::delete_post(&self.conn, id) + .await + .map(|res| res.rows_affected == 1) + .internal_call_error() + } +} + +trait IntoJsonRpcResult { + fn internal_call_error(self) -> RpcResult; +} + +impl IntoJsonRpcResult for Result +where + E: Display, +{ + fn internal_call_error(self) -> RpcResult { + self.map_err(|e| jsonrpsee::core::Error::Call(CallError::Failed(anyhow!("{}", e)))) + } +} + +#[tokio::main] +async fn start() -> std::io::Result<()> { + let _ = TermLogger::init( + LevelFilter::Trace, + Config::default(), + TerminalMode::Mixed, + ColorChoice::Auto, + ); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + + let server = HttpServerBuilder::default() + .build(server_url.parse::().unwrap()) + .unwrap(); + + let rpc_impl = PpcImpl { conn }; + let server_addr = server.local_addr().unwrap(); + let handle = server.start(rpc_impl.into_rpc()).unwrap(); + + info!("starting listening {}", server_addr); + let mut sig_int = signal(SignalKind::interrupt()).unwrap(); + let mut sig_term = signal(SignalKind::terminate()).unwrap(); + + tokio::select! { + _ = sig_int.recv() => info!("receive SIGINT"), + _ = sig_term.recv() => info!("receive SIGTERM"), + _ = ctrl_c() => info!("receive Ctrl C"), + } + handle.stop().unwrap(); + info!("Shutdown program"); + Ok(()) +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/jsonrpsee_example/core/Cargo.toml b/examples/jsonrpsee_example/core/Cargo.toml new file mode 100644 index 000000000..31ebbfcca --- /dev/null +++ b/examples/jsonrpsee_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "jsonrpsee-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-tokio-native-tls", + "sqlx-sqlite", + # "sqlx-postgres", + # "sqlx-mysql", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/jsonrpsee_example/core/src/lib.rs b/examples/jsonrpsee_example/core/src/lib.rs new file mode 100644 index 000000000..4a80f2391 --- /dev/null +++ b/examples/jsonrpsee_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/jsonrpsee_example/core/src/mutation.rs b/examples/jsonrpsee_example/core/src/mutation.rs new file mode 100644 index 000000000..dd6891d4a --- /dev/null +++ b/examples/jsonrpsee_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/jsonrpsee_example/core/src/query.rs b/examples/jsonrpsee_example/core/src/query.rs new file mode 100644 index 000000000..e8d2668f5 --- /dev/null +++ b/examples/jsonrpsee_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/jsonrpsee_example/core/tests/mock.rs b/examples/jsonrpsee_example/core/tests/mock.rs new file mode 100644 index 000000000..068f31b6c --- /dev/null +++ b/examples/jsonrpsee_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use jsonrpsee_example_core::{Mutation, Query}; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/jsonrpsee_example/core/tests/prepare.rs b/examples/jsonrpsee_example/core/tests/prepare.rs new file mode 100644 index 000000000..451804937 --- /dev/null +++ b/examples/jsonrpsee_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/jsonrpsee_example/src/main.rs b/examples/jsonrpsee_example/src/main.rs index e1d26f328..2625a68d0 100644 --- a/examples/jsonrpsee_example/src/main.rs +++ b/examples/jsonrpsee_example/src/main.rs @@ -1,148 +1,3 @@ -use std::env; - -use anyhow::anyhow; -use entity::post; -use jsonrpsee::core::{async_trait, RpcResult}; -use jsonrpsee::http_server::HttpServerBuilder; -use jsonrpsee::proc_macros::rpc; -use jsonrpsee::types::error::CallError; -use log::info; -use migration::{Migrator, MigratorTrait}; -use sea_orm::NotSet; -use sea_orm::{entity::*, query::*, DatabaseConnection}; -use simplelog::*; -use std::fmt::Display; -use std::net::SocketAddr; -use tokio::signal::ctrl_c; -use tokio::signal::unix::{signal, SignalKind}; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[rpc(server, client)] -pub trait PostRpc { - #[method(name = "Post.List")] - async fn list( - &self, - page: Option, - posts_per_page: Option, - ) -> RpcResult>; - - #[method(name = "Post.Insert")] - async fn insert(&self, p: post::Model) -> RpcResult; - - #[method(name = "Post.Update")] - async fn update(&self, p: post::Model) -> RpcResult; - - #[method(name = "Post.Delete")] - async fn delete(&self, id: i32) -> RpcResult; -} - -pub struct PpcImpl { - conn: DatabaseConnection, -} - -#[async_trait] -impl PostRpcServer for PpcImpl { - async fn list( - &self, - page: Option, - posts_per_page: Option, - ) -> RpcResult> { - let page = page.unwrap_or(1); - let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = post::Entity::find() - .order_by_asc(post::Column::Id) - .paginate(&self.conn, posts_per_page); - paginator.fetch_page(page - 1).await.internal_call_error() - } - - async fn insert(&self, p: post::Model) -> RpcResult { - let active_post = post::ActiveModel { - id: NotSet, - title: Set(p.title), - text: Set(p.text), - }; - let new_post = active_post.insert(&self.conn).await.internal_call_error()?; - Ok(new_post.id) - } - - async fn update(&self, p: post::Model) -> RpcResult { - let update_post = post::ActiveModel { - id: Set(p.id), - title: Set(p.title), - text: Set(p.text), - }; - update_post - .update(&self.conn) - .await - .map(|_| true) - .internal_call_error() - } - async fn delete(&self, id: i32) -> RpcResult { - let post = post::Entity::find_by_id(id) - .one(&self.conn) - .await - .internal_call_error()?; - - post.unwrap() - .delete(&self.conn) - .await - .map(|res| res.rows_affected == 1) - .internal_call_error() - } -} - -pub trait IntoJsonRpcResult { - fn internal_call_error(self) -> RpcResult; -} - -impl IntoJsonRpcResult for Result -where - E: Display, -{ - fn internal_call_error(self) -> RpcResult { - self.map_err(|e| jsonrpsee::core::Error::Call(CallError::Failed(anyhow!("{}", e)))) - } -} - -#[tokio::main] -async fn main() -> std::io::Result<()> { - let _ = TermLogger::init( - LevelFilter::Trace, - Config::default(), - TerminalMode::Mixed, - ColorChoice::Auto, - ); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - - let server = HttpServerBuilder::default() - .build(server_url.parse::().unwrap()) - .unwrap(); - - let rpc_impl = PpcImpl { conn }; - let server_addr = server.local_addr().unwrap(); - let handle = server.start(rpc_impl.into_rpc()).unwrap(); - - info!("starting listening {}", server_addr); - let mut sig_int = signal(SignalKind::interrupt()).unwrap(); - let mut sig_term = signal(SignalKind::terminate()).unwrap(); - - tokio::select! { - _ = sig_int.recv() => info!("receive SIGINT"), - _ = sig_term.recv() => info!("receive SIGTERM"), - _ = ctrl_c() => info!("receive Ctrl C"), - } - handle.stop().unwrap(); - info!("Shutdown program"); - Ok(()) +fn main() { + jsonrpsee_example_api::main(); } diff --git a/examples/poem_example/Cargo.toml b/examples/poem_example/Cargo.toml index 0139a5dae..9024d729c 100644 --- a/examples/poem_example/Cargo.toml +++ b/examples/poem_example/Cargo.toml @@ -5,25 +5,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } -poem = { version = "1.2.33", features = ["static-files"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -serde = { version = "1", features = ["derive"] } -tera = "1.8.0" -dotenv = "0.15" -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-sqlite", - # "sqlx-postgres", - # "sqlx-mysql", -] +poem-example-api = { path = "api" } diff --git a/examples/poem_example/README.md b/examples/poem_example/README.md index bd4a45398..d0aa6973b 100644 --- a/examples/poem_example/README.md +++ b/examples/poem_example/README.md @@ -4,8 +4,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser after seeing the `server started` line + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/poem_example/api/Cargo.toml b/examples/poem_example/api/Cargo.toml new file mode 100644 index 000000000..379bc1815 --- /dev/null +++ b/examples/poem_example/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "poem-example-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +poem-example-core = { path = "../core" } +tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } +poem = { version = "1.2.33", features = ["static-files"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +tera = "1.8.0" +dotenv = "0.15" +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/poem_example/api/src/lib.rs b/examples/poem_example/api/src/lib.rs new file mode 100644 index 000000000..3dc8fb037 --- /dev/null +++ b/examples/poem_example/api/src/lib.rs @@ -0,0 +1,163 @@ +use std::env; + +use entity::post; +use migration::{Migrator, MigratorTrait}; +use poem::endpoint::StaticFilesEndpoint; +use poem::error::InternalServerError; +use poem::http::StatusCode; +use poem::listener::TcpListener; +use poem::web::{Data, Form, Html, Path, Query}; +use poem::{get, handler, post, EndpointExt, Error, IntoResponse, Result, Route, Server}; +use poem_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation as MutationCore, Query as QueryCore, +}; +use serde::Deserialize; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} + +#[derive(Deserialize)] +struct Params { + page: Option, + posts_per_page: Option, +} + +#[handler] +async fn create(state: Data<&AppState>, form: Form) -> Result { + let form = form.0; + let conn = &state.conn; + + MutationCore::create_post(conn, form) + .await + .map_err(InternalServerError)?; + + Ok(StatusCode::FOUND.with_header("location", "/")) +} + +#[handler] +async fn list(state: Data<&AppState>, Query(params): Query) -> Result { + let conn = &state.conn; + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = QueryCore::find_posts_in_page(conn, page, posts_per_page) + .await + .map_err(InternalServerError)?; + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + let body = state + .templates + .render("index.html.tera", &ctx) + .map_err(InternalServerError)?; + Ok(Html(body)) +} + +#[handler] +async fn new(state: Data<&AppState>) -> Result { + let ctx = tera::Context::new(); + let body = state + .templates + .render("new.html.tera", &ctx) + .map_err(InternalServerError)?; + Ok(Html(body)) +} + +#[handler] +async fn edit(state: Data<&AppState>, Path(id): Path) -> Result { + let conn = &state.conn; + + let post: post::Model = QueryCore::find_post_by_id(conn, id) + .await + .map_err(InternalServerError)? + .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = state + .templates + .render("edit.html.tera", &ctx) + .map_err(InternalServerError)?; + Ok(Html(body)) +} + +#[handler] +async fn update( + state: Data<&AppState>, + Path(id): Path, + form: Form, +) -> Result { + let conn = &state.conn; + let form = form.0; + + MutationCore::update_post_by_id(conn, id, form) + .await + .map_err(InternalServerError)?; + + Ok(StatusCode::FOUND.with_header("location", "/")) +} + +#[handler] +async fn delete(state: Data<&AppState>, Path(id): Path) -> Result { + let conn = &state.conn; + + MutationCore::delete_post(conn, id) + .await + .map_err(InternalServerError)?; + + Ok(StatusCode::FOUND.with_header("location", "/")) +} + +#[tokio::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + println!("Starting server at {}", server_url); + + let app = Route::new() + .at("/", post(create).get(list)) + .at("/new", new) + .at("/:id", get(edit).post(update)) + .at("/delete/:id", post(delete)) + .nest( + "/static", + StaticFilesEndpoint::new(concat!(env!("CARGO_MANIFEST_DIR"), "/static")), + ) + .data(state); + let server = Server::new(TcpListener::bind(format!("{}:{}", host, port))); + server.run(app).await +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/poem_example/static/css/normalize.css b/examples/poem_example/api/static/css/normalize.css similarity index 100% rename from examples/poem_example/static/css/normalize.css rename to examples/poem_example/api/static/css/normalize.css diff --git a/examples/poem_example/static/css/skeleton.css b/examples/poem_example/api/static/css/skeleton.css similarity index 100% rename from examples/poem_example/static/css/skeleton.css rename to examples/poem_example/api/static/css/skeleton.css diff --git a/examples/poem_example/static/css/style.css b/examples/poem_example/api/static/css/style.css similarity index 100% rename from examples/poem_example/static/css/style.css rename to examples/poem_example/api/static/css/style.css diff --git a/examples/poem_example/static/images/favicon.png b/examples/poem_example/api/static/images/favicon.png similarity index 100% rename from examples/poem_example/static/images/favicon.png rename to examples/poem_example/api/static/images/favicon.png diff --git a/examples/poem_example/templates/edit.html.tera b/examples/poem_example/api/templates/edit.html.tera similarity index 100% rename from examples/poem_example/templates/edit.html.tera rename to examples/poem_example/api/templates/edit.html.tera diff --git a/examples/poem_example/templates/error/404.html.tera b/examples/poem_example/api/templates/error/404.html.tera similarity index 100% rename from examples/poem_example/templates/error/404.html.tera rename to examples/poem_example/api/templates/error/404.html.tera diff --git a/examples/poem_example/templates/index.html.tera b/examples/poem_example/api/templates/index.html.tera similarity index 100% rename from examples/poem_example/templates/index.html.tera rename to examples/poem_example/api/templates/index.html.tera diff --git a/examples/poem_example/templates/layout.html.tera b/examples/poem_example/api/templates/layout.html.tera similarity index 100% rename from examples/poem_example/templates/layout.html.tera rename to examples/poem_example/api/templates/layout.html.tera diff --git a/examples/poem_example/templates/new.html.tera b/examples/poem_example/api/templates/new.html.tera similarity index 100% rename from examples/poem_example/templates/new.html.tera rename to examples/poem_example/api/templates/new.html.tera diff --git a/examples/poem_example/core/Cargo.toml b/examples/poem_example/core/Cargo.toml new file mode 100644 index 000000000..e7b64f355 --- /dev/null +++ b/examples/poem_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "poem-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + # "sqlx-mysql", + # "sqlx-postgres", + "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/poem_example/core/src/lib.rs b/examples/poem_example/core/src/lib.rs new file mode 100644 index 000000000..4a80f2391 --- /dev/null +++ b/examples/poem_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/poem_example/core/src/mutation.rs b/examples/poem_example/core/src/mutation.rs new file mode 100644 index 000000000..dd6891d4a --- /dev/null +++ b/examples/poem_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/poem_example/core/src/query.rs b/examples/poem_example/core/src/query.rs new file mode 100644 index 000000000..e8d2668f5 --- /dev/null +++ b/examples/poem_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/poem_example/core/tests/mock.rs b/examples/poem_example/core/tests/mock.rs new file mode 100644 index 000000000..e4b3ef4f5 --- /dev/null +++ b/examples/poem_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use poem_example_core::{Mutation, Query}; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/poem_example/core/tests/prepare.rs b/examples/poem_example/core/tests/prepare.rs new file mode 100644 index 000000000..451804937 --- /dev/null +++ b/examples/poem_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/poem_example/src/main.rs b/examples/poem_example/src/main.rs index 14c7c3f4f..e3c79da1b 100644 --- a/examples/poem_example/src/main.rs +++ b/examples/poem_example/src/main.rs @@ -1,162 +1,3 @@ -use std::env; - -use entity::post; -use migration::{Migrator, MigratorTrait}; -use poem::endpoint::StaticFilesEndpoint; -use poem::error::{BadRequest, InternalServerError}; -use poem::http::StatusCode; -use poem::listener::TcpListener; -use poem::web::{Data, Form, Html, Path, Query}; -use poem::{get, handler, post, EndpointExt, Error, IntoResponse, Result, Route, Server}; -use sea_orm::{entity::*, query::*, DatabaseConnection}; -use serde::Deserialize; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} - -#[derive(Deserialize)] -struct Params { - page: Option, - posts_per_page: Option, -} - -#[handler] -async fn create(state: Data<&AppState>, form: Form) -> Result { - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(&state.conn) - .await - .map_err(InternalServerError)?; - - Ok(StatusCode::FOUND.with_header("location", "/")) -} - -#[handler] -async fn list(state: Data<&AppState>, Query(params): Query) -> Result { - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = post::Entity::find() - .order_by_asc(post::Column::Id) - .paginate(&state.conn, posts_per_page); - let num_pages = paginator.num_pages().await.map_err(BadRequest)?; - let posts = paginator - .fetch_page(page - 1) - .await - .map_err(InternalServerError)?; - - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - let body = state - .templates - .render("index.html.tera", &ctx) - .map_err(InternalServerError)?; - Ok(Html(body)) -} - -#[handler] -async fn new(state: Data<&AppState>) -> Result { - let ctx = tera::Context::new(); - let body = state - .templates - .render("new.html.tera", &ctx) - .map_err(InternalServerError)?; - Ok(Html(body)) -} - -#[handler] -async fn edit(state: Data<&AppState>, Path(id): Path) -> Result { - let post: post::Model = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(InternalServerError)? - .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?; - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = state - .templates - .render("edit.html.tera", &ctx) - .map_err(InternalServerError)?; - Ok(Html(body)) -} - -#[handler] -async fn update( - state: Data<&AppState>, - Path(id): Path, - form: Form, -) -> Result { - post::ActiveModel { - id: Set(id), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(&state.conn) - .await - .map_err(InternalServerError)?; - - Ok(StatusCode::FOUND.with_header("location", "/")) -} - -#[handler] -async fn delete(state: Data<&AppState>, Path(id): Path) -> Result { - let post: post::ActiveModel = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(InternalServerError)? - .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))? - .into(); - post.delete(&state.conn) - .await - .map_err(InternalServerError)?; - - Ok(StatusCode::FOUND.with_header("location", "/")) -} - -#[tokio::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - println!("Starting server at {}", server_url); - - let app = Route::new() - .at("/", post(create).get(list)) - .at("/new", new) - .at("/:id", get(edit).post(update)) - .at("/delete/:id", post(delete)) - .nest( - "/static", - StaticFilesEndpoint::new(concat!(env!("CARGO_MANIFEST_DIR"), "/static")), - ) - .data(state); - let server = Server::new(TcpListener::bind(format!("{}:{}", host, port))); - server.run(app).await +fn main() { + poem_example_api::main(); } diff --git a/examples/rocket_example/Cargo.toml b/examples/rocket_example/Cargo.toml index e16a7aebf..992ad0360 100644 --- a/examples/rocket_example/Cargo.toml +++ b/examples/rocket_example/Cargo.toml @@ -6,33 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -async-stream = { version = "^0.3" } -async-trait = { version = "0.1" } -futures = { version = "^0.3" } -futures-util = { version = "^0.3" } -rocket = { version = "0.5.0-rc.1", features = [ - "json", -] } -rocket_dyn_templates = { version = "0.1.0-rc.1", features = [ - "tera", -] } -serde_json = { version = "^1" } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm-rocket] -path = "../../sea-orm-rocket/lib" # remove this line in your own project and use the git line -# git = "https://github.com/SeaQL/sea-orm" - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "runtime-tokio-native-tls", - "sqlx-postgres", - # "sqlx-mysql", - # "sqlx-sqlite", -] +rocket-example-api = { path = "api" } diff --git a/examples/rocket_example/README.md b/examples/rocket_example/README.md index a1e3af0fa..20845db6a 100644 --- a/examples/rocket_example/README.md +++ b/examples/rocket_example/README.md @@ -2,10 +2,17 @@ # Rocket with SeaORM example app -1. Modify the `url` var in `Rocket.toml` to point to your chosen database +1. Modify the `url` var in `api/Rocket.toml` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-postgres",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-postgres",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser after seeing the `🚀 Rocket has launched from http://localhost:8000` line + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/rocket_example/Rocket.toml b/examples/rocket_example/Rocket.toml index fc294bd2f..41e0183c9 100644 --- a/examples/rocket_example/Rocket.toml +++ b/examples/rocket_example/Rocket.toml @@ -1,5 +1,5 @@ [default] -template_dir = "templates/" +template_dir = "api/templates/" [default.databases.sea_orm] # Mysql diff --git a/examples/rocket_example/api/Cargo.toml b/examples/rocket_example/api/Cargo.toml new file mode 100644 index 000000000..1a88308fc --- /dev/null +++ b/examples/rocket_example/api/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "rocket-example-api" +version = "0.1.0" +authors = ["Sam Samai "] +edition = "2021" +publish = false + +[dependencies] +async-stream = { version = "^0.3" } +async-trait = { version = "0.1" } +rocket-example-core = { path = "../core" } +futures = { version = "^0.3" } +futures-util = { version = "^0.3" } +rocket = { version = "0.5.0-rc.1", features = [ + "json", +] } +rocket_dyn_templates = { version = "0.1.0-rc.1", features = [ + "tera", +] } +serde_json = { version = "^1" } +entity = { path = "../entity" } +migration = { path = "../migration" } +tokio = "1.20.0" + +[dependencies.sea-orm-rocket] +path = "../../../sea-orm-rocket/lib" # remove this line in your own project and use the git line +# git = "https://github.com/SeaQL/sea-orm" diff --git a/examples/rocket_example/api/src/lib.rs b/examples/rocket_example/api/src/lib.rs new file mode 100644 index 000000000..51918c7a3 --- /dev/null +++ b/examples/rocket_example/api/src/lib.rs @@ -0,0 +1,171 @@ +#[macro_use] +extern crate rocket; + +use rocket::fairing::{self, AdHoc}; +use rocket::form::{Context, Form}; +use rocket::fs::{relative, FileServer}; +use rocket::request::FlashMessage; +use rocket::response::{Flash, Redirect}; +use rocket::{Build, Request, Rocket}; +use rocket_dyn_templates::Template; +use rocket_example_core::{Mutation, Query}; +use serde_json::json; + +use migration::MigratorTrait; +use sea_orm_rocket::{Connection, Database}; + +mod pool; +use pool::Db; + +pub use entity::post; +pub use entity::post::Entity as Post; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[get("/new")] +async fn new() -> Template { + Template::render("new", &Context::default()) +} + +#[post("/", data = "")] +async fn create(conn: Connection<'_, Db>, post_form: Form) -> Flash { + let db = conn.into_inner(); + + let form = post_form.into_inner(); + + Mutation::create_post(db, form) + .await + .expect("could not insert post"); + + Flash::success(Redirect::to("/"), "Post successfully added.") +} + +#[post("/", data = "")] +async fn update( + conn: Connection<'_, Db>, + id: i32, + post_form: Form, +) -> Flash { + let db = conn.into_inner(); + + let form = post_form.into_inner(); + + Mutation::update_post_by_id(db, id, form) + .await + .expect("could not update post"); + + Flash::success(Redirect::to("/"), "Post successfully edited.") +} + +#[get("/?&")] +async fn list( + conn: Connection<'_, Db>, + page: Option, + posts_per_page: Option, + flash: Option>, +) -> Template { + let db = conn.into_inner(); + + // Set page number and items per page + let page = page.unwrap_or(1); + let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + if page == 0 { + panic!("Page number cannot be zero"); + } + + let (posts, num_pages) = Query::find_posts_in_page(db, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + Template::render( + "index", + json! ({ + "page": page, + "posts_per_page": posts_per_page, + "num_pages": num_pages, + "posts": posts, + "flash": flash.map(FlashMessage::into_inner), + }), + ) +} + +#[get("/")] +async fn edit(conn: Connection<'_, Db>, id: i32) -> Template { + let db = conn.into_inner(); + + let post: Option = Query::find_post_by_id(db, id) + .await + .expect("could not find post"); + + Template::render( + "edit", + json! ({ + "post": post, + }), + ) +} + +#[delete("/")] +async fn delete(conn: Connection<'_, Db>, id: i32) -> Flash { + let db = conn.into_inner(); + + Mutation::delete_post(db, id) + .await + .expect("could not delete post"); + + Flash::success(Redirect::to("/"), "Post successfully deleted.") +} + +#[delete("/")] +async fn destroy(conn: Connection<'_, Db>) -> Result<(), rocket::response::Debug> { + let db = conn.into_inner(); + + Mutation::delete_all_posts(db) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +#[catch(404)] +pub fn not_found(req: &Request<'_>) -> Template { + Template::render( + "error/404", + json! ({ + "uri": req.uri() + }), + ) +} + +async fn run_migrations(rocket: Rocket) -> fairing::Result { + let conn = &Db::fetch(&rocket).unwrap().conn; + let _ = migration::Migrator::up(conn, None).await; + Ok(rocket) +} + +#[tokio::main] +async fn start() -> Result<(), rocket::Error> { + rocket::build() + .attach(Db::init()) + .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) + .mount("/", FileServer::from(relative!("/static"))) + .mount( + "/", + routes![new, create, delete, destroy, list, edit, update], + ) + .register("/", catchers![not_found]) + .attach(Template::fairing()) + .launch() + .await + .map(|_| ()) +} + +pub fn main() { + let result = start(); + + println!("Rocket: deorbit."); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/rocket_example/src/pool.rs b/examples/rocket_example/api/src/pool.rs similarity index 97% rename from examples/rocket_example/src/pool.rs rename to examples/rocket_example/api/src/pool.rs index fe5f8c6eb..b1c056779 100644 --- a/examples/rocket_example/src/pool.rs +++ b/examples/rocket_example/api/src/pool.rs @@ -1,3 +1,5 @@ +use rocket_example_core::sea_orm; + use async_trait::async_trait; use sea_orm::ConnectOptions; use sea_orm_rocket::{rocket::figment::Figment, Config, Database}; diff --git a/examples/rocket_example/static/css/normalize.css b/examples/rocket_example/api/static/css/normalize.css similarity index 100% rename from examples/rocket_example/static/css/normalize.css rename to examples/rocket_example/api/static/css/normalize.css diff --git a/examples/rocket_example/static/css/skeleton.css b/examples/rocket_example/api/static/css/skeleton.css similarity index 100% rename from examples/rocket_example/static/css/skeleton.css rename to examples/rocket_example/api/static/css/skeleton.css diff --git a/examples/rocket_example/static/css/style.css b/examples/rocket_example/api/static/css/style.css similarity index 100% rename from examples/rocket_example/static/css/style.css rename to examples/rocket_example/api/static/css/style.css diff --git a/examples/rocket_example/static/images/favicon.png b/examples/rocket_example/api/static/images/favicon.png similarity index 100% rename from examples/rocket_example/static/images/favicon.png rename to examples/rocket_example/api/static/images/favicon.png diff --git a/examples/rocket_example/templates/base.html.tera b/examples/rocket_example/api/templates/base.html.tera similarity index 100% rename from examples/rocket_example/templates/base.html.tera rename to examples/rocket_example/api/templates/base.html.tera diff --git a/examples/rocket_example/templates/edit.html.tera b/examples/rocket_example/api/templates/edit.html.tera similarity index 100% rename from examples/rocket_example/templates/edit.html.tera rename to examples/rocket_example/api/templates/edit.html.tera diff --git a/examples/rocket_example/templates/error/404.html.tera b/examples/rocket_example/api/templates/error/404.html.tera similarity index 100% rename from examples/rocket_example/templates/error/404.html.tera rename to examples/rocket_example/api/templates/error/404.html.tera diff --git a/examples/rocket_example/templates/index.html.tera b/examples/rocket_example/api/templates/index.html.tera similarity index 100% rename from examples/rocket_example/templates/index.html.tera rename to examples/rocket_example/api/templates/index.html.tera diff --git a/examples/rocket_example/templates/new.html.tera b/examples/rocket_example/api/templates/new.html.tera similarity index 100% rename from examples/rocket_example/templates/new.html.tera rename to examples/rocket_example/api/templates/new.html.tera diff --git a/examples/rocket_example/core/Cargo.toml b/examples/rocket_example/core/Cargo.toml new file mode 100644 index 000000000..a57a55608 --- /dev/null +++ b/examples/rocket_example/core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "rocket-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "runtime-tokio-native-tls", + "sqlx-postgres", + # "sqlx-mysql", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = "1.20.0" + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/rocket_example/core/src/lib.rs b/examples/rocket_example/core/src/lib.rs new file mode 100644 index 000000000..4a80f2391 --- /dev/null +++ b/examples/rocket_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/rocket_example/core/src/mutation.rs b/examples/rocket_example/core/src/mutation.rs new file mode 100644 index 000000000..dd6891d4a --- /dev/null +++ b/examples/rocket_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/rocket_example/core/src/query.rs b/examples/rocket_example/core/src/query.rs new file mode 100644 index 000000000..e8d2668f5 --- /dev/null +++ b/examples/rocket_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/rocket_example/core/tests/mock.rs b/examples/rocket_example/core/tests/mock.rs new file mode 100644 index 000000000..84b187e5c --- /dev/null +++ b/examples/rocket_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use rocket_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/rocket_example/core/tests/prepare.rs b/examples/rocket_example/core/tests/prepare.rs new file mode 100644 index 000000000..451804937 --- /dev/null +++ b/examples/rocket_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/rocket_example/src/main.rs b/examples/rocket_example/src/main.rs index 4187f6a11..182a6875b 100644 --- a/examples/rocket_example/src/main.rs +++ b/examples/rocket_example/src/main.rs @@ -1,184 +1,3 @@ -#[macro_use] -extern crate rocket; - -use rocket::fairing::{self, AdHoc}; -use rocket::form::{Context, Form}; -use rocket::fs::{relative, FileServer}; -use rocket::request::FlashMessage; -use rocket::response::{Flash, Redirect}; -use rocket::{Build, Request, Rocket}; -use rocket_dyn_templates::Template; -use serde_json::json; - -use migration::MigratorTrait; -use sea_orm::{entity::*, query::*}; -use sea_orm_rocket::{Connection, Database}; - -mod pool; -use pool::Db; - -pub use entity::post; -pub use entity::post::Entity as Post; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[get("/new")] -async fn new() -> Template { - Template::render("new", &Context::default()) -} - -#[post("/", data = "")] -async fn create(conn: Connection<'_, Db>, post_form: Form) -> Flash { - let db = conn.into_inner(); - - let form = post_form.into_inner(); - - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(db) - .await - .expect("could not insert post"); - - Flash::success(Redirect::to("/"), "Post successfully added.") -} - -#[post("/", data = "")] -async fn update( - conn: Connection<'_, Db>, - id: i32, - post_form: Form, -) -> Flash { - let db = conn.into_inner(); - - let post: post::ActiveModel = Post::find_by_id(id).one(db).await.unwrap().unwrap().into(); - - let form = post_form.into_inner(); - - db.transaction::<_, (), sea_orm::DbErr>(|txn| { - Box::pin(async move { - post::ActiveModel { - id: post.id, - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(txn) - .await - .expect("could not edit post"); - - Ok(()) - }) - }) - .await - .unwrap(); - - Flash::success(Redirect::to("/"), "Post successfully edited.") -} - -#[get("/?&")] -async fn list( - conn: Connection<'_, Db>, - page: Option, - posts_per_page: Option, - flash: Option>, -) -> Template { - let db = conn.into_inner(); - - // Set page number and items per page - let page = page.unwrap_or(1); - let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - if page == 0 { - panic!("Page number cannot be zero"); - } - - // Setup paginator - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(db, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - - // Fetch paginated posts - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - - Template::render( - "index", - json! ({ - "page": page, - "posts_per_page": posts_per_page, - "num_pages": num_pages, - "posts": posts, - "flash": flash.map(FlashMessage::into_inner), - }), - ) -} - -#[get("/")] -async fn edit(conn: Connection<'_, Db>, id: i32) -> Template { - let db = conn.into_inner(); - - let post: Option = Post::find_by_id(id) - .one(db) - .await - .expect("could not find post"); - - Template::render( - "edit", - json! ({ - "post": post, - }), - ) -} - -#[delete("/")] -async fn delete(conn: Connection<'_, Db>, id: i32) -> Flash { - let db = conn.into_inner(); - - let post: post::ActiveModel = Post::find_by_id(id).one(db).await.unwrap().unwrap().into(); - - post.delete(db).await.unwrap(); - - Flash::success(Redirect::to("/"), "Post successfully deleted.") -} - -#[delete("/")] -async fn destroy(conn: Connection<'_, Db>) -> Result<(), rocket::response::Debug> { - let db = conn.into_inner(); - - Post::delete_many().exec(db).await.unwrap(); - Ok(()) -} - -#[catch(404)] -pub fn not_found(req: &Request<'_>) -> Template { - Template::render( - "error/404", - json! ({ - "uri": req.uri() - }), - ) -} - -async fn run_migrations(rocket: Rocket) -> fairing::Result { - let conn = &Db::fetch(&rocket).unwrap().conn; - let _ = migration::Migrator::up(conn, None).await; - Ok(rocket) -} - -#[launch] -fn rocket() -> _ { - rocket::build() - .attach(Db::init()) - .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) - .mount("/", FileServer::from(relative!("/static"))) - .mount( - "/", - routes![new, create, delete, destroy, list, edit, update], - ) - .register("/", catchers![not_found]) - .attach(Template::fairing()) +fn main() { + rocket_example_api::main(); } diff --git a/examples/salvo_example/Cargo.toml b/examples/salvo_example/Cargo.toml index a1028a771..aef022844 100644 --- a/examples/salvo_example/Cargo.toml +++ b/examples/salvo_example/Cargo.toml @@ -5,25 +5,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } -salvo = { version = "0.27", features = ["affix", "serve-static"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -serde = { version = "1", features = ["derive"] } -tera = "1.8.0" -dotenv = "0.15" -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-sqlite", - # "sqlx-postgres", - # "sqlx-mysql", -] +salvo-example-api = { path = "api" } diff --git a/examples/salvo_example/README.md b/examples/salvo_example/README.md index bd4a45398..d0aa6973b 100644 --- a/examples/salvo_example/README.md +++ b/examples/salvo_example/README.md @@ -4,8 +4,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser after seeing the `server started` line + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/salvo_example/api/Cargo.toml b/examples/salvo_example/api/Cargo.toml new file mode 100644 index 000000000..cc26a7746 --- /dev/null +++ b/examples/salvo_example/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "salvo-example-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +salvo-example-core = { path = "../core" } +tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } +salvo = { version = "0.27", features = ["affix", "serve-static"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +tera = "1.8.0" +dotenv = "0.15" +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/salvo_example/api/src/lib.rs b/examples/salvo_example/api/src/lib.rs new file mode 100644 index 000000000..225080552 --- /dev/null +++ b/examples/salvo_example/api/src/lib.rs @@ -0,0 +1,182 @@ +use std::env; + +use entity::post; +use migration::{Migrator, MigratorTrait}; +use salvo::extra::affix; +use salvo::extra::serve_static::DirHandler; +use salvo::prelude::*; +use salvo::writer::Text; +use salvo_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; +type Result = std::result::Result; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} + +#[handler] +async fn create(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + + let form = req + .extract_form::() + .await + .map_err(|_| StatusError::bad_request())?; + + Mutation::create_post(conn, form) + .await + .map_err(|_| StatusError::internal_server_error())?; + + res.redirect_found("/"); + Ok(()) +} + +#[handler] +async fn list(req: &mut Request, depot: &mut Depot) -> Result> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + + let page = req.query("page").unwrap_or(1); + let posts_per_page = req + .query("posts_per_page") + .unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = Query::find_posts_in_page(conn, page, posts_per_page) + .await + .map_err(|_| StatusError::internal_server_error())?; + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + let body = state + .templates + .render("index.html.tera", &ctx) + .map_err(|_| StatusError::internal_server_error())?; + Ok(Text::Html(body)) +} + +#[handler] +async fn new(depot: &mut Depot) -> Result> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let ctx = tera::Context::new(); + let body = state + .templates + .render("new.html.tera", &ctx) + .map_err(|_| StatusError::internal_server_error())?; + Ok(Text::Html(body)) +} + +#[handler] +async fn edit(req: &mut Request, depot: &mut Depot) -> Result> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + let id = req.param::("id").unwrap_or_default(); + + let post: post::Model = Query::find_post_by_id(conn, id) + .await + .map_err(|_| StatusError::internal_server_error())? + .ok_or_else(StatusError::not_found)?; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = state + .templates + .render("edit.html.tera", &ctx) + .map_err(|_| StatusError::internal_server_error())?; + Ok(Text::Html(body)) +} + +#[handler] +async fn update(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + let id = req.param::("id").unwrap_or_default(); + let form = req + .extract_form::() + .await + .map_err(|_| StatusError::bad_request())?; + + Mutation::update_post_by_id(conn, id, form) + .await + .map_err(|_| StatusError::internal_server_error())?; + + res.redirect_found("/"); + Ok(()) +} + +#[handler] +async fn delete(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + let id = req.param::("id").unwrap_or_default(); + + Mutation::delete_post(conn, id) + .await + .map_err(|_| StatusError::internal_server_error())?; + + res.redirect_found("/"); + Ok(()) +} + +#[tokio::main] +pub async fn main() { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + println!("Starting server at {}", server_url); + + let router = Router::new() + .hoop(affix::inject(state)) + .post(create) + .get(list) + .push(Router::with_path("new").get(new)) + .push(Router::with_path("").get(edit).post(update)) + .push(Router::with_path("delete/").post(delete)) + .push( + Router::with_path("static/<**>").get(DirHandler::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/static" + ))), + ); + + Server::new(TcpListener::bind(&format!("{}:{}", host, port))) + .serve(router) + .await; +} diff --git a/examples/salvo_example/static/css/normalize.css b/examples/salvo_example/api/static/css/normalize.css similarity index 100% rename from examples/salvo_example/static/css/normalize.css rename to examples/salvo_example/api/static/css/normalize.css diff --git a/examples/salvo_example/static/css/skeleton.css b/examples/salvo_example/api/static/css/skeleton.css similarity index 100% rename from examples/salvo_example/static/css/skeleton.css rename to examples/salvo_example/api/static/css/skeleton.css diff --git a/examples/salvo_example/static/css/style.css b/examples/salvo_example/api/static/css/style.css similarity index 100% rename from examples/salvo_example/static/css/style.css rename to examples/salvo_example/api/static/css/style.css diff --git a/examples/salvo_example/static/images/favicon.png b/examples/salvo_example/api/static/images/favicon.png similarity index 100% rename from examples/salvo_example/static/images/favicon.png rename to examples/salvo_example/api/static/images/favicon.png diff --git a/examples/salvo_example/templates/edit.html.tera b/examples/salvo_example/api/templates/edit.html.tera similarity index 100% rename from examples/salvo_example/templates/edit.html.tera rename to examples/salvo_example/api/templates/edit.html.tera diff --git a/examples/salvo_example/templates/error/404.html.tera b/examples/salvo_example/api/templates/error/404.html.tera similarity index 100% rename from examples/salvo_example/templates/error/404.html.tera rename to examples/salvo_example/api/templates/error/404.html.tera diff --git a/examples/salvo_example/templates/index.html.tera b/examples/salvo_example/api/templates/index.html.tera similarity index 100% rename from examples/salvo_example/templates/index.html.tera rename to examples/salvo_example/api/templates/index.html.tera diff --git a/examples/salvo_example/templates/layout.html.tera b/examples/salvo_example/api/templates/layout.html.tera similarity index 100% rename from examples/salvo_example/templates/layout.html.tera rename to examples/salvo_example/api/templates/layout.html.tera diff --git a/examples/salvo_example/templates/new.html.tera b/examples/salvo_example/api/templates/new.html.tera similarity index 100% rename from examples/salvo_example/templates/new.html.tera rename to examples/salvo_example/api/templates/new.html.tera diff --git a/examples/salvo_example/core/Cargo.toml b/examples/salvo_example/core/Cargo.toml new file mode 100644 index 000000000..58280ed28 --- /dev/null +++ b/examples/salvo_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "salvo-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-tokio-native-tls", + # "sqlx-mysql", + # "sqlx-postgres", + "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/salvo_example/core/src/lib.rs b/examples/salvo_example/core/src/lib.rs new file mode 100644 index 000000000..4a80f2391 --- /dev/null +++ b/examples/salvo_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/salvo_example/core/src/mutation.rs b/examples/salvo_example/core/src/mutation.rs new file mode 100644 index 000000000..dd6891d4a --- /dev/null +++ b/examples/salvo_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/salvo_example/core/src/query.rs b/examples/salvo_example/core/src/query.rs new file mode 100644 index 000000000..e8d2668f5 --- /dev/null +++ b/examples/salvo_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/salvo_example/core/tests/mock.rs b/examples/salvo_example/core/tests/mock.rs new file mode 100644 index 000000000..261652bfe --- /dev/null +++ b/examples/salvo_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use salvo_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/salvo_example/core/tests/prepare.rs b/examples/salvo_example/core/tests/prepare.rs new file mode 100644 index 000000000..451804937 --- /dev/null +++ b/examples/salvo_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/salvo_example/src/main.rs b/examples/salvo_example/src/main.rs index ba38042b7..687ea53e4 100644 --- a/examples/salvo_example/src/main.rs +++ b/examples/salvo_example/src/main.rs @@ -1,191 +1,3 @@ -use std::env; - -use entity::post; -use migration::{Migrator, MigratorTrait}; -use salvo::extra::affix; -use salvo::extra::serve_static::DirHandler; -use salvo::prelude::*; -use salvo::writer::Text; -use sea_orm::{entity::*, query::*, DatabaseConnection}; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; -type Result = std::result::Result; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} - -#[handler] -async fn create(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let form = req - .extract_form::() - .await - .map_err(|_| StatusError::bad_request())?; - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())?; - - res.redirect_found("/"); - Ok(()) -} - -#[handler] -async fn list(req: &mut Request, depot: &mut Depot) -> Result> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let page = req.query("page").unwrap_or(1); - let posts_per_page = req - .query("posts_per_page") - .unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = post::Entity::find() - .order_by_asc(post::Column::Id) - .paginate(&state.conn, posts_per_page); - let num_pages = paginator - .num_pages() - .await - .map_err(|_| StatusError::bad_request())?; - let posts = paginator - .fetch_page(page - 1) - .await - .map_err(|_| StatusError::internal_server_error())?; - - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - let body = state - .templates - .render("index.html.tera", &ctx) - .map_err(|_| StatusError::internal_server_error())?; - Ok(Text::Html(body)) -} - -#[handler] -async fn new(depot: &mut Depot) -> Result> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let ctx = tera::Context::new(); - let body = state - .templates - .render("new.html.tera", &ctx) - .map_err(|_| StatusError::internal_server_error())?; - Ok(Text::Html(body)) -} - -#[handler] -async fn edit(req: &mut Request, depot: &mut Depot) -> Result> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let id = req.param::("id").unwrap_or_default(); - let post: post::Model = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())? - .ok_or_else(StatusError::not_found)?; - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = state - .templates - .render("edit.html.tera", &ctx) - .map_err(|_| StatusError::internal_server_error())?; - Ok(Text::Html(body)) -} - -#[handler] -async fn update(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let id = req.param::("id").unwrap_or_default(); - let form = req - .extract_form::() - .await - .map_err(|_| StatusError::bad_request())?; - post::ActiveModel { - id: Set(id), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())?; - res.redirect_found("/"); - Ok(()) -} - -#[handler] -async fn delete(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let id = req.param::("id").unwrap_or_default(); - let post: post::ActiveModel = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())? - .ok_or_else(StatusError::not_found)? - .into(); - post.delete(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())?; - - res.redirect_found("/"); - Ok(()) -} - -#[tokio::main] -async fn main() { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - println!("Starting server at {}", server_url); - - let router = Router::new() - .hoop(affix::inject(state)) - .post(create) - .get(list) - .push(Router::with_path("new").get(new)) - .push(Router::with_path("").get(edit).post(update)) - .push(Router::with_path("delete/").post(delete)) - .push( - Router::with_path("static/<**>").get(DirHandler::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/static" - ))), - ); - - Server::new(TcpListener::bind(&format!("{}:{}", host, port))) - .serve(router) - .await; +fn main() { + salvo_example_api::main(); } diff --git a/examples/tonic_example/Cargo.toml b/examples/tonic_example/Cargo.toml index 4bb3346fe..d3d147048 100644 --- a/examples/tonic_example/Cargo.toml +++ b/examples/tonic_example/Cargo.toml @@ -7,37 +7,17 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] +tonic-example-api = { path = "api" } tonic = "0.7" tokio = { version = "1.17", features = ["macros", "rt-multi-thread", "full"] } -entity = { path = "entity" } -migration = { path = "migration" } -prost = "0.10.0" -serde = "1.0" - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-rustls", - # "sqlx-mysql", - "sqlx-postgres", - # "sqlx-sqlite", -] - -[lib] -path = "./src/lib.rs" [[bin]] -name="server" -path="./src/server.rs" +name = "server" +path = "./src/server.rs" [[bin]] -name="client" -path="./src/client.rs" - -[build-dependencies] -tonic-build = "0.7" +name = "client" +path = "./src/client.rs" diff --git a/examples/tonic_example/README.md b/examples/tonic_example/README.md index 22ef798f1..7c11feace 100644 --- a/examples/tonic_example/README.md +++ b/examples/tonic_example/README.md @@ -3,11 +3,20 @@ Simple implementation of gRPC using SeaORM. run server using + ```bash cargo run --bin server ``` run client using + ```bash cargo run --bin client -``` \ No newline at end of file +``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/tonic_example/api/Cargo.toml b/examples/tonic_example/api/Cargo.toml new file mode 100644 index 000000000..6253c5311 --- /dev/null +++ b/examples/tonic_example/api/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tonic-example-api" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +tonic-example-core = { path = "../core" } +tonic = "0.7" +tokio = { version = "1.17", features = ["macros", "rt-multi-thread", "full"] } +entity = { path = "../entity" } +migration = { path = "../migration" } +prost = "0.10.0" +serde = "1.0" + +[lib] +path = "./src/lib.rs" + +[build-dependencies] +tonic-build = "0.7" diff --git a/examples/tonic_example/build.rs b/examples/tonic_example/api/build.rs similarity index 100% rename from examples/tonic_example/build.rs rename to examples/tonic_example/api/build.rs diff --git a/examples/tonic_example/proto/post.proto b/examples/tonic_example/api/proto/post.proto similarity index 100% rename from examples/tonic_example/proto/post.proto rename to examples/tonic_example/api/proto/post.proto diff --git a/examples/tonic_example/api/src/lib.rs b/examples/tonic_example/api/src/lib.rs new file mode 100644 index 000000000..9b3e2582d --- /dev/null +++ b/examples/tonic_example/api/src/lib.rs @@ -0,0 +1,143 @@ +use tonic::transport::Server; +use tonic::{Request, Response, Status}; + +use entity::post; +use migration::{Migrator, MigratorTrait}; +use tonic_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; + +use std::env; + +pub mod post_mod { + tonic::include_proto!("post"); +} + +use post_mod::{ + blogpost_server::{Blogpost, BlogpostServer}, + Post, PostId, PostList, PostPerPage, ProcessStatus, +}; + +impl Post { + fn into_model(self) -> post::Model { + post::Model { + id: self.id, + title: self.title, + text: self.content, + } + } +} + +#[derive(Default)] +pub struct MyServer { + connection: DatabaseConnection, +} + +#[tonic::async_trait] +impl Blogpost for MyServer { + async fn get_posts(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + let posts_per_page = request.into_inner().per_page; + + let mut response = PostList { post: Vec::new() }; + + let (posts, _) = Query::find_posts_in_page(conn, 1, posts_per_page) + .await + .expect("Cannot find posts in page"); + + for post in posts { + response.post.push(Post { + id: post.id, + title: post.title, + content: post.text, + }); + } + + Ok(Response::new(response)) + } + + async fn add_post(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + + let input = request.into_inner().into_model(); + + let inserted = Mutation::create_post(conn, input) + .await + .expect("could not insert post"); + + let response = PostId { + id: inserted.id.unwrap(), + }; + + Ok(Response::new(response)) + } + + async fn update_post(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + let input = request.into_inner().into_model(); + + match Mutation::update_post_by_id(conn, input.id, input).await { + Ok(_) => Ok(Response::new(ProcessStatus { success: true })), + Err(_) => Ok(Response::new(ProcessStatus { success: false })), + } + } + + async fn delete_post( + &self, + request: Request, + ) -> Result, Status> { + let conn = &self.connection; + let id = request.into_inner().id; + + match Mutation::delete_post(conn, id).await { + Ok(_) => Ok(Response::new(ProcessStatus { success: true })), + Err(_) => Ok(Response::new(ProcessStatus { success: false })), + } + } + + async fn get_post_by_id(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + let id = request.into_inner().id; + + if let Some(post) = Query::find_post_by_id(conn, id).await.ok().flatten() { + Ok(Response::new(Post { + id, + title: post.title, + content: post.text, + })) + } else { + Err(Status::new( + tonic::Code::Aborted, + "Could not find post with id ".to_owned() + &id.to_string(), + )) + } + } +} + +#[tokio::main] +async fn start() -> Result<(), Box> { + let addr = "0.0.0.0:50051".parse()?; + + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + // establish database connection + let connection = Database::connect(&database_url).await?; + Migrator::up(&connection, None).await?; + + let hello_server = MyServer { connection }; + Server::builder() + .add_service(BlogpostServer::new(hello_server)) + .serve(addr) + .await?; + + Ok(()) +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/tonic_example/core/Cargo.toml b/examples/tonic_example/core/Cargo.toml new file mode 100644 index 000000000..dd9dd4e0e --- /dev/null +++ b/examples/tonic_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tonic-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-tokio-rustls", + # "sqlx-mysql", + "sqlx-postgres", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/tonic_example/core/src/lib.rs b/examples/tonic_example/core/src/lib.rs new file mode 100644 index 000000000..4a80f2391 --- /dev/null +++ b/examples/tonic_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/tonic_example/core/src/mutation.rs b/examples/tonic_example/core/src/mutation.rs new file mode 100644 index 000000000..dd6891d4a --- /dev/null +++ b/examples/tonic_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/tonic_example/core/src/query.rs b/examples/tonic_example/core/src/query.rs new file mode 100644 index 000000000..e8d2668f5 --- /dev/null +++ b/examples/tonic_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/tonic_example/core/tests/mock.rs b/examples/tonic_example/core/tests/mock.rs new file mode 100644 index 000000000..522d3e452 --- /dev/null +++ b/examples/tonic_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use tonic_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/tonic_example/core/tests/prepare.rs b/examples/tonic_example/core/tests/prepare.rs new file mode 100644 index 000000000..451804937 --- /dev/null +++ b/examples/tonic_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/tonic_example/src/client.rs b/examples/tonic_example/src/client.rs index 4cd46cb41..f63ad46a6 100644 --- a/examples/tonic_example/src/client.rs +++ b/examples/tonic_example/src/client.rs @@ -1,7 +1,7 @@ use tonic::transport::Endpoint; use tonic::Request; -use sea_orm_tonic_example::post::{blogpost_client::BlogpostClient, PostPerPage}; +use tonic_example_api::post_mod::{blogpost_client::BlogpostClient, PostPerPage}; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/examples/tonic_example/src/lib.rs b/examples/tonic_example/src/lib.rs deleted file mode 100644 index cd2028969..000000000 --- a/examples/tonic_example/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod post { - tonic::include_proto!("post"); -} diff --git a/examples/tonic_example/src/server.rs b/examples/tonic_example/src/server.rs index 0857ac7b9..a9838cdc2 100644 --- a/examples/tonic_example/src/server.rs +++ b/examples/tonic_example/src/server.rs @@ -1,130 +1,3 @@ -use tonic::transport::Server; -use tonic::{Request, Response, Status}; - -use sea_orm_tonic_example::post::{ - blogpost_server::{Blogpost, BlogpostServer}, - Post, PostId, PostList, PostPerPage, ProcessStatus, -}; - -use entity::post::{self, Entity as PostEntity}; -use migration::{Migrator, MigratorTrait}; -use sea_orm::{self, entity::*, query::*, DatabaseConnection}; - -use std::env; - -#[derive(Default)] -pub struct MyServer { - connection: DatabaseConnection, -} - -#[tonic::async_trait] -impl Blogpost for MyServer { - async fn get_posts(&self, request: Request) -> Result, Status> { - let mut response = PostList { post: Vec::new() }; - - let posts = PostEntity::find() - .order_by_asc(post::Column::Id) - .limit(request.into_inner().per_page) - .all(&self.connection) - .await - .unwrap(); - - for post in posts { - response.post.push(Post { - id: post.id, - title: post.title, - content: post.text, - }); - } - - Ok(Response::new(response)) - } - - async fn add_post(&self, request: Request) -> Result, Status> { - let input = request.into_inner(); - let insert_details = post::ActiveModel { - title: Set(input.title.clone()), - text: Set(input.content.clone()), - ..Default::default() - }; - - let response = PostId { - id: insert_details.insert(&self.connection).await.unwrap().id, - }; - - Ok(Response::new(response)) - } - - async fn update_post(&self, request: Request) -> Result, Status> { - let input = request.into_inner(); - let mut update_post: post::ActiveModel = PostEntity::find_by_id(input.id) - .one(&self.connection) - .await - .unwrap() - .unwrap() - .into(); - - update_post.title = Set(input.title.clone()); - update_post.text = Set(input.content.clone()); - - let update = update_post.update(&self.connection).await; - - match update { - Ok(_) => Ok(Response::new(ProcessStatus { success: true })), - Err(_) => Ok(Response::new(ProcessStatus { success: false })), - } - } - - async fn delete_post( - &self, - request: Request, - ) -> Result, Status> { - let delete_post: post::ActiveModel = PostEntity::find_by_id(request.into_inner().id) - .one(&self.connection) - .await - .unwrap() - .unwrap() - .into(); - - let status = delete_post.delete(&self.connection).await; - - match status { - Ok(_) => Ok(Response::new(ProcessStatus { success: true })), - Err(_) => Ok(Response::new(ProcessStatus { success: false })), - } - } - - async fn get_post_by_id(&self, request: Request) -> Result, Status> { - let post = PostEntity::find_by_id(request.into_inner().id) - .one(&self.connection) - .await - .unwrap() - .unwrap(); - - let response = Post { - id: post.id, - title: post.title, - content: post.text, - }; - Ok(Response::new(response)) - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let addr = "0.0.0.0:50051".parse()?; - - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - - // establish database connection - let connection = sea_orm::Database::connect(&database_url).await?; - Migrator::up(&connection, None).await?; - - let hello_server = MyServer { connection }; - Server::builder() - .add_service(BlogpostServer::new(hello_server)) - .serve(addr) - .await?; - - Ok(()) +fn main() { + tonic_example_api::main(); }