diff --git a/Cargo.toml b/Cargo.toml index 0319e76..801bb54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,6 @@ resolver = "2" members = [ "application", - "database_async", - "database_sync", "errors", "extractors", "getting_started", @@ -17,6 +15,10 @@ members = [ "testing", "url_dispatch", "websockets", + "diesel", + "diesel_async", + "seaorm", + "sqlx", ] [workspace.package] @@ -25,12 +27,15 @@ edition = "2021" rust-version = "1.84" [workspace.dependencies] -actix-web = { version = "*", features = ["openssl", "rustls"] } +actix-web = { version = "4.9", features = ["openssl", "rustls"] } actix-files = "0.6" -serde = { version = "*", features = ["derive"] } -serde_json = "*" -futures = "*" -env_logger = "*" -log = "*" -futures-util = "*" -uuid = { version = "1.11.0", features = ["v7", "serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1.11", features = ["v7", "serde"] } +futures = "0.3" +futures-util = "0.3" +dotenvy = "0.15" +env_logger = "0.11" +log = "0.4" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + diff --git a/diesel/README.md b/diesel/README.md index c24e083..f202aed 100644 --- a/diesel/README.md +++ b/diesel/README.md @@ -1,4 +1,4 @@ -# diesel +# Diesel Basic integration of [Diesel](https://diesel.rs) using SQLite for Actix Web. diff --git a/diesel_async/Cargo.toml b/diesel_async/Cargo.toml index 839aab4..c9f3ae8 100644 --- a/diesel_async/Cargo.toml +++ b/diesel_async/Cargo.toml @@ -7,8 +7,9 @@ edition = "2021" actix-web.workspace = true diesel = { version = "2", default-features = false, features = ["uuid"] } diesel-async = { version = "0.5", features = ["postgres", "bb8", "async-connection-wrapper"] } +tracing-subscriber.workspace = true serde.workspace = true uuid.workspace = true -dotenvy = "0.15" +dotenvy.workspace = true env_logger.workspace = true log.workspace = true diff --git a/diesel_async/README.md b/diesel_async/README.md index 8eae699..a6ac2de 100644 --- a/diesel_async/README.md +++ b/diesel_async/README.md @@ -1,6 +1,6 @@ -# diesel +# Diesel-async -Basic integration of [Diesel-async](https://github.com/weiznich/diesel_async) using PostgreSQL for Actix Web. +A basic example of interaction between Actix Web and [Diesel-async](https://github.com/weiznich/diesel_async) using PostgreSQL. ## Usage @@ -8,7 +8,7 @@ Basic integration of [Diesel-async](https://github.com/weiznich/diesel_async) us ```sh # on any OS -docker run -d --restart unless-stopped --name postgresql -e POSTGRES_USER=test-user -e POSTGRES_PASSWORD=password -p 5432:5432 -v postgres_data:/var/lib/postgresql/data postgres:alpine +docker run -d --restart unless-stopped --name postgresql -e POSTGRES_USER=adiesel-user -e POSTGRES_PASSWORD=password -p 5432:5432 -v postgres_data:/var/lib/postgresql/data postgres:alpine ``` ### Initialize PostgreSQL Database @@ -17,7 +17,7 @@ docker run -d --restart unless-stopped --name postgresql -e POSTGRES_USER=test-u cd databases/diesel-async cargo install diesel_cli --no-default-features --features postgres -echo DATABASE_URL=postgres://test-user:password@localhost:5432/test_db > .env +echo DATABASE_URL=postgres://adiesel-user:password@localhost:5432/adiesel-db > .env diesel setup diesel migration run ``` @@ -42,10 +42,10 @@ cargo run Inserts a new item into the PostgreSQL DB. -Provide a JSON payload with a name. Eg: +Provide a JSON payload with a title. Eg: ```json -{ "name": "thingamajig" } +{ "title": "thingamajig" } ``` On success, a response like the following is returned: @@ -53,7 +53,7 @@ On success, a response like the following is returned: ```json { "id": "01948982-67d0-7a55-b4b1-8b8b962d8c6b", - "name": "thingamajig" + "title": "thingamajig" } ``` @@ -63,13 +63,13 @@ On success, a response like the following is returned: Using [HTTPie]: ```sh -http POST localhost:8080/items name=thingamajig +http POST localhost:8080/items title=thingamajig ``` Using cURL: ```sh -curl -S -X POST --header "Content-Type: application/json" --data '{"name":"thingamajig"}' http://localhost:8080/items +curl -S -X POST --header "Content-Type: application/json" --data '{"title":"thingamajig"}' http://localhost:8080/items ``` @@ -98,7 +98,7 @@ curl -S http://localhost:8080/items/9e46baba-a001-4bb3-b4cf-4b3e5bab5e97 ### Explore The PostgreSQL DB ```sh -docker exec -i postgresql psql -U test-user -d test_db -c "select * from public.items" +docker exec -i postgresql psql -U test-user -d adiesel-db -c "select * from public.items" ``` ## Using Other Databases diff --git a/diesel_async/migrations/2025-01-18-144029_create_items/down.sql b/diesel_async/migrations/2025-01-18-144029_create_table_items/down.sql similarity index 100% rename from diesel_async/migrations/2025-01-18-144029_create_items/down.sql rename to diesel_async/migrations/2025-01-18-144029_create_table_items/down.sql diff --git a/diesel_async/migrations/2025-01-18-144029_create_items/up.sql b/diesel_async/migrations/2025-01-18-144029_create_table_items/up.sql similarity index 64% rename from diesel_async/migrations/2025-01-18-144029_create_items/up.sql rename to diesel_async/migrations/2025-01-18-144029_create_table_items/up.sql index 7066001..062d72a 100644 --- a/diesel_async/migrations/2025-01-18-144029_create_items/up.sql +++ b/diesel_async/migrations/2025-01-18-144029_create_table_items/up.sql @@ -1,6 +1,5 @@ --- Your SQL goes here CREATE TABLE IF NOT EXISTS items ( id uuid DEFAULT gen_random_uuid() PRIMARY KEY, - name VARCHAR NOT NULL + title VARCHAR NOT NULL ); diff --git a/diesel_async/src/actions.rs b/diesel_async/src/actions.rs index 2bbb2c9..4488fb5 100644 --- a/diesel_async/src/actions.rs +++ b/diesel_async/src/actions.rs @@ -29,7 +29,7 @@ pub async fn find_item_by_uid( /// Run query using Diesel to insert a new database row and return the result. pub async fn insert_new_item( conn: &mut AsyncPgConnection, - nm: &str, // prevent collision with `name` column imported inside the function + nm: &str, // prevent collision with `title` column imported inside the function ) -> Result { // It is common when using Diesel with Actix Web to import schema-related // modules inside a function's scope (rather than the normal module's scope) @@ -38,7 +38,7 @@ pub async fn insert_new_item( let new_item = models::Item { id: Uuid::new_v7(Timestamp::now(NoContext)), - name: nm.to_owned(), + title: nm.to_owned(), }; let item = diesel::insert_into(items) diff --git a/diesel_async/src/api.rs b/diesel_async/src/api.rs new file mode 100644 index 0000000..4274d6a --- /dev/null +++ b/diesel_async/src/api.rs @@ -0,0 +1,58 @@ +use crate::{actions, models, DbPool}; +use actix_web::{error, get, post, web, HttpResponse, Responder}; +use uuid::Uuid; + +/// Finds item by UID. +/// +/// Extracts: +/// - the database pool handle from application data +/// - an item UID from the request path +#[get("/items/{item_id}")] +async fn get_item( + pool: web::Data, + item_uid: web::Path, +) -> actix_web::Result { + let item_uid = item_uid.into_inner(); + + let mut conn = pool + .get() + .await + .expect("Couldn't get db connection from the pool"); + + let item = actions::find_item_by_uid(&mut conn, item_uid) + .await + // map diesel query errors to a 500 error response + .map_err(error::ErrorInternalServerError)?; + + Ok(match item { + // item was found; return 200 response with JSON formatted item object + Some(item) => HttpResponse::Ok().json(item), + + // item was not found; return 404 response with error message + None => HttpResponse::NotFound().body(format!("No item found with UID: {item_uid}")), + }) +} + +/// Creates new item. +/// +/// Extracts: +/// - the database pool handle from application data +/// - a JSON form containing new item info from the request body +#[post("/items")] +async fn add_item( + pool: web::Data, + form: web::Json, +) -> actix_web::Result { + let mut conn = pool + .get() + .await + .expect("Couldn't get db connection from the pool"); + + let item = actions::insert_new_item(&mut conn, &form.title) + .await + // map diesel query errors to a 500 error response + .map_err(error::ErrorInternalServerError)?; + + // item was added successfully; return 201 response with new item info + Ok(HttpResponse::Created().json(item)) +} diff --git a/diesel_async/src/main.rs b/diesel_async/src/main.rs index c4ea5ad..e0c9390 100644 --- a/diesel_async/src/main.rs +++ b/diesel_async/src/main.rs @@ -1,76 +1,21 @@ #[macro_use] extern crate diesel; -use actix_web::{error, get, middleware, post, web, App, HttpResponse, HttpServer, Responder}; +use actix_web::{middleware, web, App, HttpServer}; use diesel_async::{ pooled_connection::{bb8::Pool, AsyncDieselConnectionManager}, AsyncPgConnection, }; use dotenvy::dotenv; use std::{env, io}; -use uuid::Uuid; mod actions; +mod api; mod models; mod schema; type DbPool = Pool; -/// Finds item by UID. -/// -/// Extracts: -/// - the database pool handle from application data -/// - an item UID from the request path -#[get("/items/{item_id}")] -async fn get_item( - pool: web::Data, - item_uid: web::Path, -) -> actix_web::Result { - let item_uid = item_uid.into_inner(); - - let mut conn = pool - .get() - .await - .expect("Couldn't get db connection from the pool"); - - let item = actions::find_item_by_uid(&mut conn, item_uid) - .await - // map diesel query errors to a 500 error response - .map_err(error::ErrorInternalServerError)?; - - Ok(match item { - // item was found; return 200 response with JSON formatted item object - Some(item) => HttpResponse::Ok().json(item), - - // item was not found; return 404 response with error message - None => HttpResponse::NotFound().body(format!("No item found with UID: {item_uid}")), - }) -} - -/// Creates new item. -/// -/// Extracts: -/// - the database pool handle from application data -/// - a JSON form containing new item info from the request body -#[post("/items")] -async fn add_item( - pool: web::Data, - form: web::Json, -) -> actix_web::Result { - let mut conn = pool - .get() - .await - .expect("Couldn't get db connection from the pool"); - - let item = actions::insert_new_item(&mut conn, &form.name) - .await - // map diesel query errors to a 500 error response - .map_err(error::ErrorInternalServerError)?; - - // item was added successfully; return 201 response with new item info - Ok(HttpResponse::Created().json(item)) -} - #[actix_web::main] async fn main() -> io::Result<()> { dotenv().ok(); @@ -88,10 +33,10 @@ async fn main() -> io::Result<()> { // add request logger middleware .wrap(middleware::Logger::default()) // add route handlers - .service(add_item) - .service(get_item) + .service(api::add_item) + .service(api::get_item) }) - .bind(("127.0.0.1", 8080))? + .bind(("127.0.0.1", 5000))? .run() .await } @@ -108,11 +53,11 @@ async fn initialize_db_pool() -> DbPool { #[cfg(test)] mod tests { + use super::*; use actix_web::{http::StatusCode, test}; use diesel::prelude::*; use diesel_async::RunQueryDsl; - - use super::*; + use uuid::Uuid; #[actix_web::test] async fn item_routes() { @@ -125,8 +70,8 @@ mod tests { App::new() .app_data(web::Data::new(pool.clone())) .wrap(middleware::Logger::default()) - .service(get_item) - .service(add_item), + .service(api::get_item) + .service(api::add_item), ) .await; @@ -158,14 +103,14 @@ mod tests { .set_json(models::NewItem::new("Test item")) .to_request(); let res: models::Item = test::call_and_read_body_json(&app, req).await; - assert_eq!(res.name, "Test item"); + assert_eq!(res.title, "Test item"); // get an item let req = test::TestRequest::get() .uri(&format!("/items/{}", res.id)) .to_request(); let res: models::Item = test::call_and_read_body_json(&app, req).await; - assert_eq!(res.name, "Test item"); + assert_eq!(res.title, "Test item"); // delete new item from table use crate::schema::items::dsl::*; diff --git a/diesel_async/src/models.rs b/diesel_async/src/models.rs index 6d08565..905112e 100644 --- a/diesel_async/src/models.rs +++ b/diesel_async/src/models.rs @@ -7,19 +7,21 @@ use uuid::Uuid; #[diesel(table_name = items)] pub struct Item { pub id: Uuid, - pub name: String, + pub title: String, } /// New item details. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct NewItem { - pub name: String, + pub title: String, } impl NewItem { - /// Constructs new item details from name. + /// Constructs new item details from title. #[cfg(test)] // only needed in tests - pub fn new(name: impl Into) -> Self { - Self { name: name.into() } + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + } } } diff --git a/diesel_async/src/schema.rs b/diesel_async/src/schema.rs index a9038e3..2023fd9 100644 --- a/diesel_async/src/schema.rs +++ b/diesel_async/src/schema.rs @@ -3,6 +3,6 @@ diesel::table! { items (id) { id -> Uuid, - name -> Varchar, + title -> Varchar, } } diff --git a/diesel_async/diesel.toml b/diesel_async/this_is_optional_cfg_file_diesel.toml similarity index 76% rename from diesel_async/diesel.toml rename to diesel_async/this_is_optional_cfg_file_diesel.toml index 34c0a18..5f49e32 100644 --- a/diesel_async/diesel.toml +++ b/diesel_async/this_is_optional_cfg_file_diesel.toml @@ -6,4 +6,4 @@ file = "src/schema.rs" custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] [migrations_directory] -dir = "/home/alex/CLionProjects/actix-with-async-diesel/migrations" +dir = "/home/alex/CLionProjects/actix-practice/diesel_async/migrations" diff --git a/seaorm/Cargo.toml b/seaorm/Cargo.toml new file mode 100644 index 0000000..4026b1a --- /dev/null +++ b/seaorm/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "seaorm" +version = "1.0.0" +edition = "2021" + +[dependencies] +actix-web.workspace = true +dotenvy.workspace = true +listenfd = "1" +serde.workspace = true +tracing-subscriber.workspace = true +sea-orm = { version = "1.1", features = [ "sqlx-postgres", "runtime-tokio", "macros", "with-uuid" ] } +migration = { path = "./migration" } diff --git a/seaorm/README.md b/seaorm/README.md new file mode 100644 index 0000000..75b883d --- /dev/null +++ b/seaorm/README.md @@ -0,0 +1,110 @@ +# SeaORM + +Basic integration of [seaorm](https://github.com/SeaQL/sea-orm) using PostgreSQL for Actix Web. + +## Usage + +### Prerequisites +Set up PostgreSQL + +```sh +# on any OS +docker run -d --restart unless-stopped --name postgresql -e POSTGRES_USER=seaorm-user -e POSTGRES_PASSWORD=password -p 5432:5432 -v postgres_data:/var/lib/postgresql/data postgres:alpine +``` + +### Initialize PostgreSQL Database + +Create database +```sh +docker exec -i postgresql createdb -U seaorm-user searom-db +``` + +Create tables +```sh +cd seaorm +cargo install cargo install sea-orm-cli + +echo DATABASE_URL=postgres://sqlx-user:password@localhost:5432/searom-db > .env +sqlx sea-orm-cli migrate up +``` + +You can verify that the database has been created in your PostgreSQL instance. +```sh +docker exec -i postgresql psql -U seaorm-user -c "\l" +``` + +### Running Server + +```sh +cargo run + +# Started http server: 127.0.0.1:8080 +``` + +### Available Routes + +#### `POST /items` + +Inserts a new item into the PostgreSQL DB. + +Provide a JSON payload with a title with/out description. Eg: + +```json +{ "title": "thingamajig", "description": "This is awesome thing!" } +``` + +On success, a response like the following is returned: + +```json +{ + "id": "01948982-67d0-7a55-b4b1-8b8b962d8c6b", + "title": "thingamajig", + "description": "This is awesome thing!" +} +``` + +
+ Client Examples + +Using [HTTPie]: + +```sh +http POST localhost:8080/items title=thingamajig +``` + +Using cURL: + +```sh +curl -S -X POST --header "Content-Type: application/json" --data '{"title":"thingamajig"}' http://localhost:8080/items +``` + +
+ +#### `GET /items/{item_id}` + +Gets an item from the DB using its ID (UUID) (returned from the insert request or taken from the DB directly). Returns a 404 when no item exists with that ID. + +
+ Client Examples + +Using [HTTPie]: + +```sh +http localhost:8080/items/9e46baba-a001-4bb3-b4cf-4b3e5bab5e97 +``` + +Using cURL: + +```sh +curl -S http://localhost:8080/items/9e46baba-a001-4bb3-b4cf-4b3e5bab5e97 +``` + +
+ +### Explore The PostgreSQL DB + +```sh +docker exec -i postgresql psql -U sqlx-user -d sqlx-db -c "select * from items" +``` + +[httpie]: https://httpie.io/cli diff --git a/seaorm/migration/Cargo.toml b/seaorm/migration/Cargo.toml new file mode 100644 index 0000000..21cd3f5 --- /dev/null +++ b/seaorm/migration/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "migration" +version = "1.0.0" +edition = "2021" +publish = false + +[lib] +name = "migration" +path = "src/lib.rs" + +[dependencies] +async-std = { version = "1.13", features = ["attributes", "tokio1"] } + +[dependencies.sea-orm-migration] +#path = "../../../sea-orm-migration" # remove this line in your own project +version = "~1.1.4" # sea-orm-migration version +features = [ + # Enable following runtime and db backend features if you want to run migration via CLI + "runtime-tokio", + "sqlx-postgres", +] diff --git a/seaorm/migration/README.md b/seaorm/migration/README.md new file mode 100644 index 0000000..963caae --- /dev/null +++ b/seaorm/migration/README.md @@ -0,0 +1,37 @@ +# Running Migrator CLI + +- Apply all pending migrations + ```sh + cargo run + ``` + ```sh + cargo run -- up + ``` +- Apply first 10 pending migrations + ```sh + cargo run -- up -n 10 + ``` +- Rollback last applied migrations + ```sh + cargo run -- down + ``` +- Rollback last 10 applied migrations + ```sh + cargo run -- down -n 10 + ``` +- Drop all tables from the database, then reapply all migrations + ```sh + cargo run -- fresh + ``` +- Rollback all applied migrations, then reapply all migrations + ```sh + cargo run -- refresh + ``` +- Rollback all applied migrations + ```sh + cargo run -- reset + ``` +- Check the status of all migrations + ```sh + cargo run -- status + ``` diff --git a/seaorm/migration/src/lib.rs b/seaorm/migration/src/lib.rs new file mode 100644 index 0000000..4cc94e3 --- /dev/null +++ b/seaorm/migration/src/lib.rs @@ -0,0 +1,12 @@ +pub use sea_orm_migration::prelude::*; + +mod m20220120_000001_create_item_table; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![Box::new(m20220120_000001_create_item_table::Migration)] + } +} diff --git a/seaorm/migration/src/m20220120_000001_create_item_table.rs b/seaorm/migration/src/m20220120_000001_create_item_table.rs new file mode 100644 index 0000000..556cb58 --- /dev/null +++ b/seaorm/migration/src/m20220120_000001_create_item_table.rs @@ -0,0 +1,35 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Items::Table) + .if_not_exists() + .col(pk_uuid(Items::Id).default(Expr::cust("gen_random_uuid()"))) + .col(string(Items::Title)) + .col(string_null(Items::Description).null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Items::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Items { + Table, + Id, + Title, + Description, +} diff --git a/seaorm/migration/src/main.rs b/seaorm/migration/src/main.rs new file mode 100644 index 0000000..c6b6e48 --- /dev/null +++ b/seaorm/migration/src/main.rs @@ -0,0 +1,6 @@ +use sea_orm_migration::prelude::*; + +#[async_std::main] +async fn main() { + cli::run_cli(migration::Migrator).await; +} diff --git a/seaorm/src/actions.rs b/seaorm/src/actions.rs new file mode 100644 index 0000000..4ac62bd --- /dev/null +++ b/seaorm/src/actions.rs @@ -0,0 +1,66 @@ +use crate::api::UpdateItemModel; +use crate::models::{ActiveModel, Column, Entity as Item, Model}; +use sea_orm::prelude::Uuid; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_item(db: &DbConn, data: Model) -> Result { + ActiveModel { + title: Set(data.title.to_owned()), + description: Set(data.description.to_owned()), + ..Default::default() + } + .insert(db) + .await + } + + pub async fn update_item(db: &DbConn, id: Uuid, data: UpdateItemModel) -> Result { + let item: ActiveModel = Item::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find item.".to_owned())) + .map(Into::into)?; + + ActiveModel { + id: item.id, + title: data.title.map(Set).unwrap_or(item.title), + description: data.description.into_active_value(), + } + .update(db) + .await + } + + pub async fn delete_item(db: &DbConn, id: Uuid) -> Result { + let item: ActiveModel = Item::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find item.".to_owned())) + .map(Into::into)?; + + item.delete(db).await + } +} + +pub struct Query; + +impl Query { + pub async fn find_item_by_id(db: &DbConn, id: Uuid) -> Result, DbErr> { + Item::find_by_id(id).one(db).await + } + + /// If ok, returns (item models, num pages). + pub async fn find_items_in_page( + db: &DbConn, + page: u64, + items_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + let paginator = Item::find() + .order_by_asc(Column::Id) + .paginate(db, items_per_page); + let num_pages = paginator.num_pages().await?; + + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/seaorm/src/api.rs b/seaorm/src/api.rs new file mode 100644 index 0000000..edf9dae --- /dev/null +++ b/seaorm/src/api.rs @@ -0,0 +1,106 @@ +use crate::actions::{Mutation, Query}; +use crate::models::Model; +use crate::{AppState, DEFAULT_POSTS_PER_PAGE}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse, Responder}; +use sea_orm::prelude::Uuid; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct Params { + page: Option, + items_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +#[post("/items")] +async fn create_item( + data: web::Data, + item_data: web::Json, +) -> actix_web::Result { + let conn = &data.conn; + + let item = Mutation::create_item(conn, item_data.into_inner()) + .await + .expect("could not insert item"); + + Ok(HttpResponse::Created().json(item)) +} + +#[get("/items/{id}")] +async fn get_item( + data: web::Data, + id: web::Path, +) -> actix_web::Result { + let conn = &data.conn; + + let id = id.into_inner(); + + let item: Option = Query::find_item_by_id(conn, id) + .await + .expect("could not find item"); + + Ok(HttpResponse::Ok().json(item)) +} + +#[get("/items")] +async fn list_items( + req: HttpRequest, + data: web::Data, +) -> actix_web::Result { + let conn = &data.conn; + + // get params + let params = web::Query::::from_query(req.query_string())?; + + let page = params.page.unwrap_or(1); + let items_per_page = params.items_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (items, num_pages) = Query::find_items_in_page(conn, page, items_per_page) + .await + .expect("Cannot find items in page"); + + Ok(HttpResponse::Ok().json(items)) +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct UpdateItemModel { + pub title: Option, + pub description: Option, +} + +#[patch("/items/{id}")] +async fn update_item( + data: web::Data, + id: web::Path, + item_data: web::Json, +) -> actix_web::Result { + let conn = &data.conn; + // let form = item_data.into_inner(); + let id = id.into_inner(); + + let item = Mutation::update_item(conn, id, item_data.into_inner()) + .await + .expect("could not edit item"); + + Ok(HttpResponse::Accepted().json(item)) +} + +#[delete("/items/{id}")] +async fn delete_item( + data: web::Data, + id: web::Path, +) -> actix_web::Result { + let conn = &data.conn; + let id = id.into_inner(); + + Mutation::delete_item(conn, id) + .await + .expect("could not delete item"); + + Ok(HttpResponse::NoContent()) +} diff --git a/seaorm/src/main.rs b/seaorm/src/main.rs new file mode 100644 index 0000000..fc8892a --- /dev/null +++ b/seaorm/src/main.rs @@ -0,0 +1,68 @@ +mod actions; +mod api; +mod models; + +use actix_web::{middleware, web, App, HttpServer}; + +use listenfd::ListenFd; +use migration::{Migrator, MigratorTrait}; +use sea_orm::{Database, DatabaseConnection}; +use std::env; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + conn: DatabaseConnection, +} + +#[actix_web::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + dotenvy::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.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + + let state = AppState { conn }; //, templates }; + + let mut listenfd = ListenFd::from_env(); + let mut server = HttpServer::new(move || { + App::new() + .app_data(web::Data::new(state.clone())) + .wrap(middleware::Logger::default()) // enable logger + .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(api::create_item); + cfg.service(api::list_items); + cfg.service(api::get_item); + cfg.service(api::update_item); + cfg.service(api::delete_item); +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {err}"); + } +} diff --git a/seaorm/src/models.rs b/seaorm/src/models.rs new file mode 100644 index 0000000..51f4af7 --- /dev/null +++ b/seaorm/src/models.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Deserialize, Serialize)] +#[sea_orm(table_name = "items")] +pub struct Model { + #[sea_orm(primary_key)] + #[serde(skip_deserializing)] + pub id: Uuid, + pub title: String, + #[sea_orm(column_type = "Text")] + pub description: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/seaorm/src/tests/mock.rs b/seaorm/src/tests/mock.rs new file mode 100644 index 0000000..45cdba3 --- /dev/null +++ b/seaorm/src/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use actix_example_service::{Mutation, Query}; +use entity::item; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let item = Query::find_item_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(item.id, 1); + } + + { + let item = Query::find_item_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(item.id, 5); + } + + { + let item = Mutation::create_item( + db, + item::Model { + id: 0, + title: "Title D".to_owned(), + description: "Description D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + item, + item::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + description: sea_orm::ActiveValue::Unchanged("Description D".to_owned()) + } + ); + } + + { + let item = Mutation::update_item_by_id( + db, + 1, + item::Model { + id: 1, + title: "New Title A".to_owned(), + description: "New Description A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + item, + item::Model { + id: 1, + title: "New Title A".to_owned(), + description: "New Description A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_item(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_items(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/seaorm/src/tests/mod.rs b/seaorm/src/tests/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/seaorm/src/tests/prepare.rs b/seaorm/src/tests/prepare.rs new file mode 100644 index 0000000..2cff786 --- /dev/null +++ b/seaorm/src/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::item; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Itemgres) + .append_query_results([ + [item::Model { + id: 1, + title: "Title A".to_owned(), + description: "Description A".to_owned(), + }], + [item::Model { + id: 5, + title: "Title C".to_owned(), + description: "Description C".to_owned(), + }], + [item::Model { + id: 6, + title: "Title D".to_owned(), + description: "Description D".to_owned(), + }], + [item::Model { + id: 1, + title: "Title A".to_owned(), + description: "Description A".to_owned(), + }], + [item::Model { + id: 1, + title: "New Title A".to_owned(), + description: "New Description A".to_owned(), + }], + [item::Model { + id: 5, + title: "Title C".to_owned(), + description: "Description C".to_owned(), + }], + ]) + .append_exec_results([ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/sqlx/.gitignore b/sqlx/.gitignore new file mode 100644 index 0000000..e2f287b --- /dev/null +++ b/sqlx/.gitignore @@ -0,0 +1,2 @@ +todo.db +todo.db-* diff --git a/sqlx/.sqlx/query-56fe0b1d42e9e3999405f165f97fb199e7017d876a6d7c0e8720d8ec4561dcaa.json b/sqlx/.sqlx/query-56fe0b1d42e9e3999405f165f97fb199e7017d876a6d7c0e8720d8ec4561dcaa.json new file mode 100644 index 0000000..9615a6f --- /dev/null +++ b/sqlx/.sqlx/query-56fe0b1d42e9e3999405f165f97fb199e7017d876a6d7c0e8720d8ec4561dcaa.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT *\n FROM items\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "56fe0b1d42e9e3999405f165f97fb199e7017d876a6d7c0e8720d8ec4561dcaa" +} diff --git a/sqlx/.sqlx/query-f93cdfd729c79b6e83806dc5c005584579a09bad66b28e1390533fc5811ac924.json b/sqlx/.sqlx/query-f93cdfd729c79b6e83806dc5c005584579a09bad66b28e1390533fc5811ac924.json new file mode 100644 index 0000000..59fcf04 --- /dev/null +++ b/sqlx/.sqlx/query-f93cdfd729c79b6e83806dc5c005584579a09bad66b28e1390533fc5811ac924.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM items\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f93cdfd729c79b6e83806dc5c005584579a09bad66b28e1390533fc5811ac924" +} diff --git a/sqlx/Cargo.toml b/sqlx/Cargo.toml new file mode 100644 index 0000000..4aa5f03 --- /dev/null +++ b/sqlx/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "sqlx" +version = "1.0.0" +edition = "2021" + +[dependencies] +actix-web.workspace = true +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "migrate", "macros"] } +uuid.workspace = true +serde.workspace = true +dotenvy.workspace = true +env_logger.workspace = true +log.workspace = true diff --git a/sqlx/README.md b/sqlx/README.md new file mode 100644 index 0000000..f201ac0 --- /dev/null +++ b/sqlx/README.md @@ -0,0 +1,105 @@ +# SQLx + +Basic integration of [sqlx](https://github.com/launchbadge/sqlx) using PostgreSQL for Actix Web. + +## Usage + +### Prerequisites +Set up PostgreSQL + +```sh +# on any OS +docker run -d --restart unless-stopped --name postgresql -e POSTGRES_USER=sqlx-user -e POSTGRES_PASSWORD=password -p 5432:5432 -v postgres_data:/var/lib/postgresql/data postgres:alpine +``` + +### Initialize PostgreSQL Database + +```sh +cd sqlx +cargo install sqlx-cli --no-default-features --features postgres + +echo DATABASE_URL=postgres://sqlx-user:password@localhost:5432/sqlx-db > .env +sqlx database create +sqlx migrate run +``` + +You can verify that the database has been created in your PostgreSQL instance. +```sh +docker exec -i postgresql psql -U sqlx-user -c "\l" +``` + +### Running Server + +```sh +cargo run + +# Started http server: 127.0.0.1:8080 +``` + +### Available Routes + +#### `POST /items` + +Inserts a new item into the PostgreSQL DB. + +Provide a JSON payload with a name with/out description. Eg: + +```json +{ "name": "thingamajig", "description": "This is awesome thing!" } +``` + +On success, a response like the following is returned: + +```json +{ + "id": "01948982-67d0-7a55-b4b1-8b8b962d8c6b", + "name": "thingamajig", + "description": "This is awesome thing!" +} +``` + +
+ Client Examples + +Using [HTTPie]: + +```sh +http POST localhost:8080/items name=thingamajig +``` + +Using cURL: + +```sh +curl -S -X POST --header "Content-Type: application/json" --data '{"name":"thingamajig"}' http://localhost:8080/items +``` + +
+ +#### `GET /items/{item_id}` + +Gets an item from the DB using its ID (UUID) (returned from the insert request or taken from the DB directly). Returns a 404 when no item exists with that ID. + +
+ Client Examples + +Using [HTTPie]: + +```sh +http localhost:8080/items/9e46baba-a001-4bb3-b4cf-4b3e5bab5e97 +``` + +Using cURL: + +```sh +curl -S http://localhost:8080/items/9e46baba-a001-4bb3-b4cf-4b3e5bab5e97 +``` + +
+ +### Explore The PostgreSQL DB + +```sh +docker exec -i postgresql psql -U sqlx-user -d sqlx-db -c "select * from items" +``` + +[httpie]: https://httpie.io/cli diff --git a/sqlx/migrations/20220205163207_create_items_table.down.sql b/sqlx/migrations/20220205163207_create_items_table.down.sql new file mode 100644 index 0000000..f3f9b64 --- /dev/null +++ b/sqlx/migrations/20220205163207_create_items_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS items; diff --git a/sqlx/migrations/20220205163207_create_items_table.up.sql b/sqlx/migrations/20220205163207_create_items_table.up.sql new file mode 100644 index 0000000..e1fc834 --- /dev/null +++ b/sqlx/migrations/20220205163207_create_items_table.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS items +( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + title VARCHAR NOT NULL, + description VARCHAR NOT NULL +); diff --git a/sqlx/src/actions.rs b/sqlx/src/actions.rs new file mode 100644 index 0000000..c42e1e1 --- /dev/null +++ b/sqlx/src/actions.rs @@ -0,0 +1,39 @@ +use crate::models::{Item, NewItem, UpdateItem}; +use sqlx::postgres::{PgPool, PgPoolOptions}; +use sqlx::types::Uuid; + +pub async fn init_pool(database_url: &str) -> Result { + PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect(database_url) + .await +} + +pub async fn get_all_items(pool: &PgPool) -> Result, &'static str> { + Item::all(pool).await.map_err(|_| "Error retrieving items") +} + +pub async fn create_item(item_data: NewItem, pool: &PgPool) -> Result<(), &'static str> { + let new_item = NewItem { + title: item_data.title, + description: item_data.description, + }; + Item::create(new_item, pool) + .await + .map(|_| ()) + .map_err(|_| "Error inserting item") +} + +pub async fn update_item(id: Uuid, data: UpdateItem, pool: &PgPool) -> Result<(), &'static str> { + Item::update(id, data, pool) + .await + .map(|_| ()) + .map_err(|_| "Error toggling item completion") +} + +pub async fn delete_item(id: Uuid, pool: &PgPool) -> Result<(), &'static str> { + Item::delete(id, pool) + .await + .map(|_| ()) + .map_err(|_| "Error deleting item") +} diff --git a/sqlx/src/api.rs b/sqlx/src/api.rs new file mode 100644 index 0000000..24383de --- /dev/null +++ b/sqlx/src/api.rs @@ -0,0 +1,93 @@ +use actix_web::{error, http::StatusCode, web, Error, HttpResponse, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::UpdateItem; +use crate::{actions, models}; + +pub async fn get_items(pool: web::Data) -> Result { + let items = actions::get_all_items(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(HttpResponse::Ok().json(items)) +} + +pub async fn create_item( + params: web::Json, + pool: web::Data, +) -> Result { + actions::create_item(params.into_inner(), &pool) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(web::Redirect::to("/").using_status_code(StatusCode::FOUND)) + // } +} + +#[derive(Debug, Deserialize)] +pub struct UpdateParams { + id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateForm { + _method: String, +} + +pub async fn update_item( + pool: web::Data, + id: web::Path, + data: web::Json, +) -> Result<&'static str, Error> { + actions::update_item(id.into_inner(), data.into_inner(), &pool) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok("/") +} + +pub async fn delete_item( + pool: web::Data, + params: web::Path, +) -> Result<&'static str, Error> { + actions::delete_item(params.id, &pool) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok("/") +} + +// pub fn bad_request(res: dev::ServiceResponse) -> Result> { +// let new_resp = NamedFile::open("static/errors/400.html")? +// .customize() +// .with_status(res.status()) +// .respond_to(res.request()) +// .map_into_boxed_body() +// .map_into_right_body(); +// +// Ok(ErrorHandlerResponse::Response(res.into_response(new_resp))) +// } +// +// pub fn not_found(res: dev::ServiceResponse) -> Result> { +// let new_resp = NamedFile::open("static/errors/404.html")? +// .customize() +// .with_status(res.status()) +// .respond_to(res.request()) +// .map_into_boxed_body() +// .map_into_right_body(); +// +// Ok(ErrorHandlerResponse::Response(res.into_response(new_resp))) +// } + +// pub fn internal_server_error(res: dev::ServiceResponse) -> Result> { +// let new_resp = NamedFile::open("static/errors/500.html")? +// .customize() +// .with_status(res.status()) +// .respond_to(res.request()) +// .map_into_boxed_body() +// .map_into_right_body(); +// +// Ok(ErrorHandlerResponse::Response(res.into_response(new_resp))) +// } diff --git a/sqlx/src/main.rs b/sqlx/src/main.rs new file mode 100644 index 0000000..28b7931 --- /dev/null +++ b/sqlx/src/main.rs @@ -0,0 +1,50 @@ +use std::{env, io}; + +use actix_web::{ + http, + middleware::{ErrorHandlers, Logger}, + web, App, HttpServer, +}; +use dotenvy::dotenv; + +mod actions; +mod api; +mod models; + +#[actix_web::main] +async fn main() -> io::Result<()> { + dotenv().ok(); + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = actions::init_pool(&database_url) + .await + .expect("Failed to create pool"); + + log::info!("starting HTTP server at http://localhost:8080"); + + HttpServer::new(move || { + log::debug!("Constructing the App"); + + // let error_handlers = ErrorHandlers::new() + // .handler( + // http::StatusCode::INTERNAL_SERVER_ERROR, + // api::internal_server_error, + // ) + // .handler(http::StatusCode::BAD_REQUEST, api::bad_request) + // .handler(http::StatusCode::NOT_FOUND, api::not_found); + + App::new() + .app_data(web::Data::new(pool.clone())) + .wrap(Logger::default()) + // .wrap(error_handlers) + .service(web::resource("/items").route(web::get().to(api::get_items))) + .service(web::resource("/items").route(web::post().to(api::create_item))) + .service(web::resource("/items/{id}").route(web::patch().to(api::update_item))) + .service(web::resource("/items/{id}").route(web::delete().to(api::delete_item))) + }) + .bind(("127.0.0.1", 8080))? + .workers(2) + .run() + .await +} diff --git a/sqlx/src/models.rs b/sqlx/src/models.rs new file mode 100644 index 0000000..5802827 --- /dev/null +++ b/sqlx/src/models.rs @@ -0,0 +1,132 @@ +use serde::{Deserialize, Serialize}; +use sqlx::types::Uuid; +use sqlx::PgPool; +use std::collections::HashMap; + +#[derive(Debug, Deserialize)] +pub struct NewItem { + pub title: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateItem { + pub title: Option, + pub description: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Item { + pub id: Uuid, + pub title: String, + pub description: Option, +} + +impl Item { + pub async fn all(connection: &PgPool) -> Result, sqlx::Error> { + let items = sqlx::query_as!( + Item, + r#" + SELECT * + FROM items + "# + ) + .fetch_all(connection) + .await?; + + Ok(items) + } + + pub async fn create(item_data: NewItem, connection: &PgPool) -> Result<(), sqlx::Error> { + let mut updates = Vec::new(); + let mut params = HashMap::new(); + + // if let Some(title) = item_data.title { + // updates.push("title = $1"); + // params.insert(1, title); + // } + if let Some(description) = item_data.description { + updates.push("description = $2"); + params.insert(2, description); + } + + // if updates.is_empty() { + // return Ok(()); // Нет данных для обновления + // } + + let query = format!( + "INSERT INTO items VALUES ({}, {})", + item_data.title, + updates.join("") + ); + + let mut query_builder = sqlx::query(&query); + for (i, value) in params { + query_builder = query_builder.bind(value); + } + // query_builder = query_builder.bind(id); + + query_builder.execute(connection).await?; + + // sqlx::query!( + // r#" + // INSERT INTO items (title, description) + // VALUES ($1, $2) + // "#, + // item_data.title, + // item_data.description, + // ) + // .execute(connection) + // .await?; + + Ok(()) + } + + pub async fn update( + id: Uuid, + item_data: UpdateItem, + connection: &PgPool, + ) -> Result<(), sqlx::Error> { + let mut updates = Vec::new(); + let mut params = HashMap::new(); + + if let Some(title) = item_data.title { + updates.push("title = $1"); + params.insert(1, title); + } + if let Some(description) = item_data.description { + updates.push("description = $2"); + params.insert(2, description); + } + + if updates.is_empty() { + return Ok(()); // Нет данных для обновления + } + + let query = format!("UPDATE items SET {} WHERE id = $3", updates.join(", ")); + + let mut query_builder = sqlx::query(&query); + for (i, value) in params { + query_builder = query_builder.bind(value); + } + query_builder = query_builder.bind(id); + + query_builder.execute(connection).await?; + + Ok(()) + } + + pub async fn delete(id: Uuid, connection: &PgPool) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM items + WHERE id = $1 + "#, + id + ) + .execute(connection) + .await?; + + Ok(()) + } +}