Demonstrate how to mock test SeaORM by separating core implementation from the web API (#890)

* Move core implementations to a standalone crate

* Set up integration test skeleton in `core`

* Demonstrate mock testing with query

* Move Rocket api code to a standalone crate

* Add mock execution

* Add MyDataMyConsent in COMMUNITY.md (#889)

* Add MyDataMyConsent in COMMUNITY.md

* Update MyDataMyConsent description in COMMUNITY.md

* Update COMMUNITY.md

Chronological order

* [cli] bump sea-schema to 0.9.3 (SeaQL/sea-orm#876)

* Update CHNAGELOG PR links

* 0.9.1 CHANGELOG

* Auto discover and run all issues & examples CI (#903)

* Auto discover and run all [issues] CI

* Auto discover and run all examples CI

* Fixup

* Testing

* Test [issues]

* Compile prepare_mock_db() conditionally based on "mock" feature

* Update workflow job to run mock test if `core` folder exists

* Update Actix3 example

* Fix merge conflict human error

* Update usize used in paginate to u64 (PR#789)

* Update sea-orm version in the Rocket example to 0.10.0

* Fix GitHub workflow to run mock test for core crates

* Increase the robustness of core crate check by verifying that the `core` folder is a crate

* Update Actix(4) example

* Update Axum example

* Update GraphQL example

* Update Jsonrpsee example

* Update Poem example

* Update Tonic example

* Cargo fmt

* Update Salvo example

* Update path of core/Cargo.toml in README.md

* Add mock test instruction in README.md

* Refactoring

* Fix Rocket examples

Co-authored-by: Amit Goyani <63532626+itsAmitGoyani@users.noreply.github.com>
Co-authored-by: Billy Chan <ccw.billy.123@gmail.com>
This commit is contained in:
Sanford Pun 2022-09-23 05:57:43 +01:00 committed by GitHub
parent f3fb355647
commit 5143307b3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
169 changed files with 4055 additions and 1789 deletions

View File

@ -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

View File

@ -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" }

View File

@ -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
```

View File

@ -0,0 +1,22 @@
[package]
name = "actix3-example-api"
version = "0.1.0"
authors = ["Sam Samai <sam@studio2pi.com.au>"]
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" }

View File

@ -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<u64>,
posts_per_page: Option<u64>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct FlashData {
kind: String,
message: String,
}
#[get("/")]
async fn list(
req: HttpRequest,
data: web::Data<AppState>,
opt_flash: Option<actix_flash::Message<FlashData>>,
) -> Result<HttpResponse, Error> {
let template = &data.templates;
let conn = &data.conn;
// get params
let params = web::Query::<Params>::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<AppState>) -> Result<HttpResponse, Error> {
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<AppState>,
post_form: web::Form<post::Model>,
) -> actix_flash::Response<HttpResponse, FlashData> {
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<AppState>, id: web::Path<i32>) -> Result<HttpResponse, Error> {
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<AppState>,
id: web::Path<i32>,
post_form: web::Form<post::Model>,
) -> actix_flash::Response<HttpResponse, FlashData> {
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<AppState>,
id: web::Path<i32>,
) -> actix_flash::Response<HttpResponse, FlashData> {
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)
}
}

View File

@ -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"]

View File

@ -0,0 +1,7 @@
mod mutation;
mod query;
pub use mutation::*;
pub use query::*;
pub use sea_orm;

View File

@ -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, DbErr> {
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<post::Model, DbErr> {
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<DeleteResult, DbErr> {
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<DeleteResult, DbErr> {
Post::delete_many().exec(db).await
}
}

View File

@ -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<Option<post::Model>, 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<post::Model>, 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))
}
}

View File

@ -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);
}
}

View File

@ -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()
}

View File

@ -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<u64>,
posts_per_page: Option<u64>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct FlashData {
kind: String,
message: String,
}
#[get("/")]
async fn list(
req: HttpRequest,
data: web::Data<AppState>,
opt_flash: Option<actix_flash::Message<FlashData>>,
) -> Result<HttpResponse, Error> {
let template = &data.templates;
let conn = &data.conn;
// get params
let params = web::Query::<Params>::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<AppState>) -> Result<HttpResponse, Error> {
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<AppState>,
post_form: web::Form<post::Model>,
) -> actix_flash::Response<HttpResponse, FlashData> {
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<AppState>, id: web::Path<i32>) -> Result<HttpResponse, Error> {
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<AppState>,
id: web::Path<i32>,
post_form: web::Form<post::Model>,
) -> actix_flash::Response<HttpResponse, FlashData> {
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<AppState>,
id: web::Path<i32>,
) -> actix_flash::Response<HttpResponse, FlashData> {
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();
}

View File

@ -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" }

View File

@ -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
```

View File

@ -0,0 +1,21 @@
[package]
name = "actix-example-api"
version = "0.1.0"
authors = ["Sam Samai <sam@studio2pi.com.au>"]
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" }

View File

@ -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<u64>,
posts_per_page: Option<u64>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct FlashData {
kind: String,
message: String,
}
#[get("/")]
async fn list(req: HttpRequest, data: web::Data<AppState>) -> Result<HttpResponse, Error> {
let template = &data.templates;
let conn = &data.conn;
// get params
let params = web::Query::<Params>::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<AppState>) -> Result<HttpResponse, Error> {
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<AppState>,
post_form: web::Form<post::Model>,
) -> Result<HttpResponse, Error> {
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<AppState>, id: web::Path<i32>) -> Result<HttpResponse, Error> {
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<AppState>,
id: web::Path<i32>,
post_form: web::Form<post::Model>,
) -> Result<HttpResponse, Error> {
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<AppState>, id: web::Path<i32>) -> Result<HttpResponse, Error> {
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<AppState>, request: HttpRequest) -> Result<HttpResponse, Error> {
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);
}
}

View File

@ -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"]

View File

@ -0,0 +1,7 @@
mod mutation;
mod query;
pub use mutation::*;
pub use query::*;
pub use sea_orm;

View File

@ -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, DbErr> {
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<post::Model, DbErr> {
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<DeleteResult, DbErr> {
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<DeleteResult, DbErr> {
Post::delete_many().exec(db).await
}
}

View File

@ -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<Option<post::Model>, 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<post::Model>, 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))
}
}

View File

@ -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);
}
}

View File

@ -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()
}

View File

@ -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<u64>,
posts_per_page: Option<u64>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct FlashData {
kind: String,
message: String,
}
#[get("/")]
async fn list(req: HttpRequest, data: web::Data<AppState>) -> Result<HttpResponse, Error> {
let template = &data.templates;
let conn = &data.conn;
// get params
let params = web::Query::<Params>::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<AppState>) -> Result<HttpResponse, Error> {
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<AppState>,
post_form: web::Form<post::Model>,
) -> Result<HttpResponse, Error> {
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<AppState>, id: web::Path<i32>) -> Result<HttpResponse, Error> {
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<AppState>,
id: web::Path<i32>,
post_form: web::Form<post::Model>,
) -> Result<HttpResponse, Error> {
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<AppState>, id: web::Path<i32>) -> Result<HttpResponse, Error> {
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<AppState>, request: HttpRequest) -> Result<HttpResponse, Error> {
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();
}

View File

@ -5,32 +5,8 @@ authors = ["Yoshiera Huang <huangjasper@126.com>"]
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" }

View File

@ -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
```

View File

@ -0,0 +1,22 @@
[package]
name = "axum-example-api"
version = "0.1.0"
authors = ["Yoshiera Huang <huangjasper@126.com>"]
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" }

View File

@ -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<u64>,
posts_per_page: Option<u64>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct FlashData {
kind: String,
message: String,
}
async fn list_posts(
Extension(ref templates): Extension<Tera>,
Extension(ref conn): Extension<DatabaseConnection>,
Query(params): Query<Params>,
cookies: Cookies,
) -> Result<Html<String>, (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::<FlashData>(&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<Tera>,
) -> Result<Html<String>, (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<DatabaseConnection>,
form: Form<post::Model>,
mut cookies: Cookies,
) -> Result<PostResponse, (StatusCode, &'static str)> {
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<Tera>,
Extension(ref conn): Extension<DatabaseConnection>,
Path(id): Path<i32>,
) -> Result<Html<String>, (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<DatabaseConnection>,
Path(id): Path<i32>,
form: Form<post::Model>,
mut cookies: Cookies,
) -> Result<PostResponse, (StatusCode, String)> {
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<DatabaseConnection>,
Path(id): Path<i32>,
mut cookies: Cookies,
) -> Result<PostResponse, (StatusCode, &'static str)> {
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);
}
}

View File

@ -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"]

View File

@ -0,0 +1,7 @@
mod mutation;
mod query;
pub use mutation::*;
pub use query::*;
pub use sea_orm;

View File

@ -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, DbErr> {
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<post::Model, DbErr> {
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<DeleteResult, DbErr> {
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<DeleteResult, DbErr> {
Post::delete_many().exec(db).await
}
}

View File

@ -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<Option<post::Model>, 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<post::Model>, 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))
}
}

View File

@ -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);
}
}

View File

@ -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()
}

View File

@ -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<u64>,
posts_per_page: Option<u64>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct FlashData {
kind: String,
message: String,
}
async fn list_posts(
Extension(ref templates): Extension<Tera>,
Extension(ref conn): Extension<DatabaseConnection>,
Query(params): Query<Params>,
cookies: Cookies,
) -> Result<Html<String>, (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::<FlashData>(&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<Tera>,
) -> Result<Html<String>, (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<DatabaseConnection>,
form: Form<post::Model>,
mut cookies: Cookies,
) -> Result<PostResponse, (StatusCode, &'static str)> {
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<Tera>,
Extension(ref conn): Extension<DatabaseConnection>,
Path(id): Path<i32>,
) -> Result<Html<String>, (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<DatabaseConnection>,
Path(id): Path<i32>,
form: Form<post::Model>,
mut cookies: Cookies,
) -> Result<PostResponse, (StatusCode, String)> {
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<DatabaseConnection>,
Path(id): Path<i32>,
mut cookies: Cookies,
) -> Result<PostResponse, (StatusCode, &'static str)> {
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();
}

View File

@ -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" }

View File

@ -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
```

View File

@ -0,0 +1,15 @@
[package]
name = "graphql-example-api"
authors = ["Aaron Leopold <aaronleopold1221@gmail.com>"]
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" }

View File

@ -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
}
}

View File

@ -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<note::Model> {
let db = ctx.data::<Database>().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<DeleteResult> {
let db = ctx.data::<Database>().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 {

View File

@ -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<Vec<note::Model>> {
let db = ctx.data::<Database>().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<Option<note::Model>> {
let db = ctx.data::<Database>().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())?)
}

View File

@ -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<AppSchema>, 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();
}

View File

@ -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"]

View File

@ -0,0 +1,7 @@
mod mutation;
mod query;
pub use mutation::*;
pub use query::*;
pub use sea_orm;

View File

@ -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<note::Model, DbErr> {
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<note::Model, DbErr> {
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<DeleteResult, DbErr> {
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<DeleteResult, DbErr> {
Note::delete_many().exec(db).await
}
}

View File

@ -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<Option<note::Model>, DbErr> {
Note::find_by_id(id).one(db).await
}
pub async fn get_all_notes(db: &DbConn) -> Result<Vec<note::Model>, 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<note::Model>, 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))
}
}

View File

@ -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);
}
}

View File

@ -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()
}

View File

@ -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
}
}

View File

@ -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<AppSchema>, 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();
}

View File

@ -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" }

View File

@ -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
}'
```
```
Run mock test on the core logic crate:
```bash
cd core
cargo test --features mock
```

View File

@ -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 = "*"

View File

@ -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<u64>,
posts_per_page: Option<u64>,
) -> RpcResult<Vec<post::Model>>;
#[method(name = "Post.Insert")]
async fn insert(&self, p: post::Model) -> RpcResult<i32>;
#[method(name = "Post.Update")]
async fn update(&self, p: post::Model) -> RpcResult<bool>;
#[method(name = "Post.Delete")]
async fn delete(&self, id: i32) -> RpcResult<bool>;
}
struct PpcImpl {
conn: DatabaseConnection,
}
#[async_trait]
impl PostRpcServer for PpcImpl {
async fn list(
&self,
page: Option<u64>,
posts_per_page: Option<u64>,
) -> RpcResult<Vec<post::Model>> {
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<i32> {
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<bool> {
Mutation::update_post_by_id(&self.conn, p.id, p)
.await
.map(|_| true)
.internal_call_error()
}
async fn delete(&self, id: i32) -> RpcResult<bool> {
Mutation::delete_post(&self.conn, id)
.await
.map(|res| res.rows_affected == 1)
.internal_call_error()
}
}
trait IntoJsonRpcResult<T> {
fn internal_call_error(self) -> RpcResult<T>;
}
impl<T, E> IntoJsonRpcResult<T> for Result<T, E>
where
E: Display,
{
fn internal_call_error(self) -> RpcResult<T> {
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::<SocketAddr>().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);
}
}

View File

@ -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"]

View File

@ -0,0 +1,7 @@
mod mutation;
mod query;
pub use mutation::*;
pub use query::*;
pub use sea_orm;

View File

@ -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, DbErr> {
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<post::Model, DbErr> {
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<DeleteResult, DbErr> {
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<DeleteResult, DbErr> {
Post::delete_many().exec(db).await
}
}

View File

@ -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<Option<post::Model>, 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<post::Model>, 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))
}
}

View File

@ -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);
}
}

View File

@ -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()
}

View File

@ -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<u64>,
posts_per_page: Option<u64>,
) -> RpcResult<Vec<post::Model>>;
#[method(name = "Post.Insert")]
async fn insert(&self, p: post::Model) -> RpcResult<i32>;
#[method(name = "Post.Update")]
async fn update(&self, p: post::Model) -> RpcResult<bool>;
#[method(name = "Post.Delete")]
async fn delete(&self, id: i32) -> RpcResult<bool>;
}
pub struct PpcImpl {
conn: DatabaseConnection,
}
#[async_trait]
impl PostRpcServer for PpcImpl {
async fn list(
&self,
page: Option<u64>,
posts_per_page: Option<u64>,
) -> RpcResult<Vec<post::Model>> {
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<i32> {
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<bool> {
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<bool> {
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<T> {
fn internal_call_error(self) -> RpcResult<T>;
}
impl<T, E> IntoJsonRpcResult<T> for Result<T, E>
where
E: Display,
{
fn internal_call_error(self) -> RpcResult<T> {
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::<SocketAddr>().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();
}

View File

@ -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" }

View File

@ -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
```

View File

@ -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" }

View File

@ -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<u64>,
posts_per_page: Option<u64>,
}
#[handler]
async fn create(state: Data<&AppState>, form: Form<post::Model>) -> Result<impl IntoResponse> {
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<Params>) -> Result<impl IntoResponse> {
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<impl IntoResponse> {
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<i32>) -> Result<impl IntoResponse> {
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<i32>,
form: Form<post::Model>,
) -> Result<impl IntoResponse> {
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<i32>) -> Result<impl IntoResponse> {
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);
}
}

Some files were not shown because too many files have changed in this diff Show More