diff --git a/.github/workflows/ci-nightly.yml b/.github/workflows/ci-nightly.yml index d9ff04c7..d86bde1a 100644 --- a/.github/workflows/ci-nightly.yml +++ b/.github/workflows/ci-nightly.yml @@ -21,16 +21,21 @@ jobs: with: toolchain: ${{ matrix.version }} + - name: Install system packages + run: | + sudo apt-get update + sudo apt-get -y install sqlite3 + sudo apt-get -y install libpq-dev + - name: Install DB CLI tools run: | cargo install --force sqlx-cli --no-default-features --features=sqlite,rustls - cargo install --force diesel_cli --no-default-features --features sqlite + cargo install --force diesel_cli --no-default-features --features=sqlite,postgres - name: Create Test DBs env: DATABASE_URL: sqlite://./todo.db run: | - sudo apt-get update && sudo apt-get install sqlite3 sqlx database create chmod a+rwx ./todo.db sqlx migrate run --source=./basics/todo/migrations diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b15551d9..24580c04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,16 +26,21 @@ jobs: with: toolchain: ${{ matrix.version }} + - name: Install system packages + run: | + sudo apt-get update + sudo apt-get -y install sqlite3 + sudo apt-get -y install libpq-dev + - name: Install DB CLI tools run: | cargo install --force sqlx-cli --no-default-features --features=sqlite,rustls - cargo install --force diesel_cli --no-default-features --features sqlite + cargo install --force diesel_cli --no-default-features --features=sqlite,postgres - name: Create Test DBs env: DATABASE_URL: sqlite://./todo.db run: | - sudo apt-get update && sudo apt-get install sqlite3 sqlx database create chmod a+rwx ./todo.db sqlx migrate run --source=./basics/todo/migrations diff --git a/Cargo.lock b/Cargo.lock index adf639d8..98485b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1730,6 +1730,18 @@ dependencies = [ "log", ] +[[package]] +name = "bb8" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89aabfae550a5c44b43ab941844ffcd2e993cb6900b342debf59e9ea74acdb8" +dependencies = [ + "async-trait", + "futures-util", + "parking_lot", + "tokio", +] + [[package]] name = "bigdecimal" version = "0.3.1" @@ -2633,6 +2645,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "db-diesel-async" +version = "1.0.0" +dependencies = [ + "actix-web", + "diesel", + "diesel-async", + "dotenvy", + "env_logger", + "log", + "serde", + "uuid", +] + [[package]] name = "db-mongo" version = "0.0.0" @@ -2663,7 +2689,7 @@ version = "0.0.0" dependencies = [ "actix-web", "env_logger", - "redis 0.27.6", + "redis 0.28.2", "serde", "tracing", ] @@ -2860,6 +2886,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "diesel-async" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a307ac00f7c23f526a04a77761a0519b9f0eb2838ebf5b905a58580095bdcb" +dependencies = [ + "async-trait", + "bb8", + "diesel", + "futures-util", + "scoped-futures", + "tokio", + "tokio-postgres", +] + [[package]] name = "diesel_derives" version = "2.2.4" @@ -6538,6 +6579,29 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redis" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37ec3fd44bea2ec947ba6cc7634d7999a6590aca7c35827c250bc0de502bda6" +dependencies = [ + "arc-swap", + "backon", + "bytes", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", "socket2", "tokio", "tokio-util", @@ -7154,6 +7218,15 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "scoped-futures" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b24aae2d0636530f359e9d5ef0c04669d11c5e756699b27a6a6d845d8329091" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "scopeguard" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 8eeb0c9d..a234b0e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "cors/backend", "data-factory", "databases/diesel", + "databases/diesel-async", "databases/mongodb", "databases/mysql", "databases/postgres", diff --git a/databases/diesel-async/Cargo.toml b/databases/diesel-async/Cargo.toml new file mode 100644 index 00000000..fbf35f2f --- /dev/null +++ b/databases/diesel-async/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "db-diesel-async" +version = "1.0.0" +edition = "2021" + +[features] +postgres_tests = [] + +[dependencies] +actix-web.workspace = true +diesel = { version = "2", default-features = false, features = ["uuid"] } +diesel-async = { version = "0.5", features = ["postgres", "bb8", "async-connection-wrapper"] } +serde.workspace = true +uuid.workspace = true +dotenvy.workspace = true +env_logger.workspace = true +log.workspace = true diff --git a/databases/diesel-async/README.md b/databases/diesel-async/README.md new file mode 100644 index 00000000..891e55b5 --- /dev/null +++ b/databases/diesel-async/README.md @@ -0,0 +1,113 @@ +# diesel + +Basic integration of [Diesel-async](https://github.com/weiznich/diesel_async) using PostgreSQL for Actix Web. + +## Usage + +### Install PostgreSQL + +```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 +``` +make sure it has successfully started up and is running +```sh +# on any OS +docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}" +``` + +### Initialize PostgreSQL Database + +```sh +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 +diesel setup +diesel migration run +``` + +The database will now be created in your PostgreSQL instance. +```sh +docker exec -i postgresql psql -U test-user -c "\l" +``` + +### Running Server + +```sh +cd databases/diesel-async +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. Eg: + +```json +{ "name": "thingamajig" } +``` + +On success, a response like the following is returned: + +```json +{ + "id": "01948982-67d0-7a55-b4b1-8b8b962d8c6b", + "name": "thingamajig" +} +``` + +
+ 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_uid}` + +Gets an item from the DB using its UID (returned from the insert request or taken from the DB directly). Returns a 404 when no item exists with that UID. + +
+ 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 test-user -d test_db -c "select * from public.items" +``` + +## Using Other Databases + +You can find a complete example of Diesel + PostgreSQL at: [https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Rust/actix](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Rust/actix) + +[httpie]: https://httpie.io/cli diff --git a/databases/diesel-async/diesel.toml b/databases/diesel-async/diesel.toml new file mode 100644 index 00000000..34c0a182 --- /dev/null +++ b/databases/diesel-async/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] + +[migrations_directory] +dir = "/home/alex/CLionProjects/actix-with-async-diesel/migrations" diff --git a/databases/diesel-async/migrations/.keep b/databases/diesel-async/migrations/.keep new file mode 100644 index 00000000..e69de29b diff --git a/databases/diesel-async/migrations/00000000000000_diesel_initial_setup/down.sql b/databases/diesel-async/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 00000000..a9f52609 --- /dev/null +++ b/databases/diesel-async/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/databases/diesel-async/migrations/00000000000000_diesel_initial_setup/up.sql b/databases/diesel-async/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 00000000..8335c232 --- /dev/null +++ b/databases/diesel-async/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS +$$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS +$$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/databases/diesel-async/migrations/2025-01-18-144029_create_items/down.sql b/databases/diesel-async/migrations/2025-01-18-144029_create_items/down.sql new file mode 100644 index 00000000..6839ef8e --- /dev/null +++ b/databases/diesel-async/migrations/2025-01-18-144029_create_items/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE IF EXISTS items; diff --git a/databases/diesel-async/migrations/2025-01-18-144029_create_items/up.sql b/databases/diesel-async/migrations/2025-01-18-144029_create_items/up.sql new file mode 100644 index 00000000..7066001b --- /dev/null +++ b/databases/diesel-async/migrations/2025-01-18-144029_create_items/up.sql @@ -0,0 +1,6 @@ +-- Your SQL goes here +CREATE TABLE IF NOT EXISTS items +( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + name VARCHAR NOT NULL +); diff --git a/databases/diesel-async/src/actions.rs b/databases/diesel-async/src/actions.rs new file mode 100644 index 00000000..542f5662 --- /dev/null +++ b/databases/diesel-async/src/actions.rs @@ -0,0 +1,52 @@ +use diesel::prelude::*; +use uuid::{NoContext, Timestamp, Uuid}; + +use crate::models; + +use diesel_async::AsyncPgConnection; +use diesel_async::RunQueryDsl; + +type DbError = Box; + +// /// Run query using Diesel to find item by uid and return it. +pub async fn find_item_by_id( + conn: &mut AsyncPgConnection, + uid: Uuid, +) -> Result, DbError> { + use super::schema::items::dsl::*; + + let item = items + .filter(id.eq(uid)) + .select(models::Item::as_select()) + // execute the query via the provided async `diesel_async::RunQueryDsl` + .first::(conn) + .await + .optional()?; + + Ok(item) +} + +/// 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 +) -> 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) + // to prevent import collisions and namespace pollution. + use crate::schema::items::dsl::*; + + let new_item = models::Item { + id: Uuid::new_v7(Timestamp::now(NoContext)), + name: nm.to_owned(), + }; + + let item = diesel::insert_into(items) + .values(&new_item) + .returning(models::Item::as_returning()) + .get_result(conn) + .await + .expect("Error inserting person"); + + Ok(item) +} diff --git a/databases/diesel-async/src/main.rs b/databases/diesel-async/src/main.rs new file mode 100644 index 00000000..5d315c01 --- /dev/null +++ b/databases/diesel-async/src/main.rs @@ -0,0 +1,183 @@ +#[macro_use] +extern crate diesel; + +use actix_web::{error, get, middleware, post, web, App, HttpResponse, HttpServer, Responder}; +use diesel_async::{ + pooled_connection::{bb8::Pool, AsyncDieselConnectionManager}, + AsyncPgConnection, +}; +use dotenvy::dotenv; +use std::{env, io}; +use uuid::Uuid; + +mod actions; +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_id: web::Path, +) -> actix_web::Result { + let item_id = item_id.into_inner(); + + let mut conn = pool + .get() + .await + .expect("Couldn't get db connection from the pool"); + + let item = actions::find_item_by_id(&mut conn, item_id) + .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_id}")), + }) +} + +/// 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(); + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + // initialize DB pool outside `HttpServer::new` so that it is shared across all workers + let pool = initialize_db_pool().await; + + log::info!("starting HTTP server at http://localhost:8080"); + + HttpServer::new(move || { + App::new() + // add DB pool handle to app data; enables use of `web::Data` extractor + .app_data(web::Data::new(pool.clone())) + // add request logger middleware + .wrap(middleware::Logger::default()) + // add route handlers + .service(add_item) + .service(get_item) + }) + .bind(("127.0.0.1", 8080))? + .run() + .await +} + +/// Initialize database connection pool based on `DATABASE_URL` environment variable. +/// +/// See more: . +async fn initialize_db_pool() -> DbPool { + let db_url = env::var("DATABASE_URL").expect("Env var `DATABASE_URL` not set"); + + let connection_manager = AsyncDieselConnectionManager::::new(db_url); + Pool::builder().build(connection_manager).await.unwrap() +} + +#[cfg(not(feature = "postgres_tests"))] +#[allow(unused_imports)] +mod tests { + use actix_web::{http::StatusCode, test}; + use diesel::prelude::*; + use diesel_async::RunQueryDsl; + + use super::*; + + #[actix_web::test] + async fn item_routes() { + dotenv().ok(); + env_logger::try_init_from_env(env_logger::Env::new().default_filter_or("info")).ok(); + + let pool = initialize_db_pool().await; + + let app = test::init_service( + App::new() + .app_data(web::Data::new(pool.clone())) + .wrap(middleware::Logger::default()) + .service(get_item) + .service(add_item), + ) + .await; + + // send something that isn't a UUID to `get_item` + let req = test::TestRequest::get().uri("/items/123").to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let body = test::read_body(res).await; + assert!( + body.starts_with(b"UUID parsing failed"), + "unexpected body: {body:?}", + ); + + // try to find a non-existent item + let req = test::TestRequest::get() + .uri(&format!("/items/{}", Uuid::nil())) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let body = test::read_body(res).await; + assert!( + body.starts_with(b"No item found"), + "unexpected body: {body:?}", + ); + + // create new item + let req = test::TestRequest::post() + .uri("/items") + .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"); + + // 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"); + + // delete new item from table + use crate::schema::items::dsl::*; + diesel::delete(items.filter(id.eq(res.id))) + .execute( + &mut pool + .get() + .await + .expect("couldn't get db connection from pool"), + ) + .await + .expect("couldn't delete test item from table"); + } +} diff --git a/databases/diesel-async/src/models.rs b/databases/diesel-async/src/models.rs new file mode 100644 index 00000000..610839ba --- /dev/null +++ b/databases/diesel-async/src/models.rs @@ -0,0 +1,26 @@ +use super::schema::items; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Item details. +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Selectable, Insertable)] +#[diesel(table_name = items)] +pub struct Item { + pub id: Uuid, + pub name: String, +} + +/// New item details. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NewItem { + pub name: String, +} + +#[cfg(not(feature = "postgres_tests"))] +impl NewItem { + /// Constructs new item details from name. + #[cfg(test)] // only needed in tests + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} diff --git a/databases/diesel-async/src/schema.rs b/databases/diesel-async/src/schema.rs new file mode 100644 index 00000000..a9038e33 --- /dev/null +++ b/databases/diesel-async/src/schema.rs @@ -0,0 +1,8 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + items (id) { + id -> Uuid, + name -> Varchar, + } +}