From 3edf84ae3dab7e631d53d73e5ea0c3eac560cbdf Mon Sep 17 00:00:00 2001 From: Milan Zivkovic Date: Tue, 21 Apr 2020 09:54:32 +0200 Subject: [PATCH] Todo API example using Actix-web and SQLx with PostgreSQL database --- Cargo.lock | 56 ++++++++ examples/postgres/todo-api/.env-example | 4 + examples/postgres/todo-api/.gitignore | 2 + examples/postgres/todo-api/Cargo.toml | 20 +++ examples/postgres/todo-api/README.md | 33 +++++ examples/postgres/todo-api/schema.sql | 5 + examples/postgres/todo-api/src/main.rs | 67 +++++++++ examples/postgres/todo-api/src/todo/mod.rs | 5 + examples/postgres/todo-api/src/todo/model.rs | 130 ++++++++++++++++++ examples/postgres/todo-api/src/todo/routes.rs | 63 +++++++++ 10 files changed, 385 insertions(+) create mode 100644 examples/postgres/todo-api/.env-example create mode 100644 examples/postgres/todo-api/.gitignore create mode 100644 examples/postgres/todo-api/Cargo.toml create mode 100644 examples/postgres/todo-api/README.md create mode 100644 examples/postgres/todo-api/schema.sql create mode 100644 examples/postgres/todo-api/src/main.rs create mode 100644 examples/postgres/todo-api/src/todo/mod.rs create mode 100644 examples/postgres/todo-api/src/todo/model.rs create mode 100644 examples/postgres/todo-api/src/todo/routes.rs diff --git a/Cargo.lock b/Cargo.lock index c6971c19..bd8e5d9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,6 +305,8 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "console", + "dialoguer", "dotenv", "futures 0.3.4", "sqlx", @@ -352,6 +354,18 @@ dependencies = [ "vec_map", ] +[[package]] +name = "clicolors-control" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90082ee5dcdd64dc4e9e0d37fbf3ee325419e39c0092191e0393df65518f741e" +dependencies = [ + "atty", + "lazy_static", + "libc", + "winapi 0.3.8", +] + [[package]] name = "cloudabi" version = "0.0.3" @@ -361,6 +375,22 @@ dependencies = [ "bitflags", ] +[[package]] +name = "console" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6728a28023f207181b193262711102bfbaf47cc9d13bc71d0736607ef8efe88c" +dependencies = [ + "clicolors-control", + "encode_unicode", + "lazy_static", + "libc", + "regex", + "termios", + "unicode-width", + "winapi 0.3.8", +] + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -476,6 +506,17 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11c0346158a19b3627234e15596f5e465c360fcdb97d817bcb255e0510f5a788" +[[package]] +name = "dialoguer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94616e25d2c04fc97253d145f6ca33ad84a584258dc70c4e621cc79a57f903b6" +dependencies = [ + "console", + "lazy_static", + "tempfile", +] + [[package]] name = "digest" version = "0.8.1" @@ -503,6 +544,12 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb1f6b1ce1c140482ea30ddd3335fc0024ac7ee112895426e0a629a6c20adfe3" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "env_logger" version = "0.7.1" @@ -2048,6 +2095,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termios" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0fcee7b24a25675de40d5bb4de6e41b0df07bc9856295e7e2b3a3600c400c2" +dependencies = [ + "libc", +] + [[package]] name = "textwrap" version = "0.11.0" diff --git a/examples/postgres/todo-api/.env-example b/examples/postgres/todo-api/.env-example new file mode 100644 index 00000000..8d793070 --- /dev/null +++ b/examples/postgres/todo-api/.env-example @@ -0,0 +1,4 @@ +HOST=127.0.0.1 +PORT=5000 +DATABASE_URL="postgres://user:pass@192.168.33.11/actix_sqlx_todo" +RUST_LOG=actix_rest_api_sqlx=info,actix=info diff --git a/examples/postgres/todo-api/.gitignore b/examples/postgres/todo-api/.gitignore new file mode 100644 index 00000000..fedaa2b1 --- /dev/null +++ b/examples/postgres/todo-api/.gitignore @@ -0,0 +1,2 @@ +/target +.env diff --git a/examples/postgres/todo-api/Cargo.toml b/examples/postgres/todo-api/Cargo.toml new file mode 100644 index 00000000..df08d6b3 --- /dev/null +++ b/examples/postgres/todo-api/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "actix_sqlx" +version = "0.1.0" +authors = ["Milan Zivkovic "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +listenfd = "0.3.3" +actix-web = "3.0.0-alpha.1" +actix-rt = "1.1.0" +serde = "1.0.106" +serde_json = "1.0.51" +sqlx = { version = "0.3", features = [ "postgres" ] } +futures = "0.3.4" +dotenv = "0.15.0" +env_logger = "0.7.1" +log = "0.4.8" +anyhow = "1.0.28" \ No newline at end of file diff --git a/examples/postgres/todo-api/README.md b/examples/postgres/todo-api/README.md new file mode 100644 index 00000000..05661b8d --- /dev/null +++ b/examples/postgres/todo-api/README.md @@ -0,0 +1,33 @@ +# actix-sqlx-todo + +Example Todo API using [Actix-web](https://github.com/actix/actix-web) and SQLx with posgresql + +# Usage + +## Prerequisites + +* Rust +* PostgreSQL + +## Change into the project sub-directory + +All instructions assume you have changed into this folder: + +```bash +cd examples/postgres/todo-api +``` + +## Set up the database + +* Create new database using `schema.sql` +* Copy `.env-example` into `.env` and adjust DATABASE_URL to match your PostgreSQL address, username and password + +## Run the application + +To run the application execute: + +```bash +cargo run +``` + +By default application will be available on `http://localhost:5000`. If you wish to change address or port you can do it inside `.env` file. \ No newline at end of file diff --git a/examples/postgres/todo-api/schema.sql b/examples/postgres/todo-api/schema.sql new file mode 100644 index 00000000..dd9dc7ff --- /dev/null +++ b/examples/postgres/todo-api/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS todos ( + id SERIAL PRIMARY KEY, + description TEXT NOT NULL, + done BOOLEAN NOT NULL DEFAULT FALSE +); diff --git a/examples/postgres/todo-api/src/main.rs b/examples/postgres/todo-api/src/main.rs new file mode 100644 index 00000000..fb8596ce --- /dev/null +++ b/examples/postgres/todo-api/src/main.rs @@ -0,0 +1,67 @@ +#[macro_use] +extern crate log; + +use dotenv::dotenv; +use listenfd::ListenFd; +use std::env; +use actix_web::{web, App, HttpResponse, HttpServer, Responder}; +use sqlx::PgPool; +use anyhow::Result; + +// import todo module (routes and model) +mod todo; + +// default / handler +async fn index() -> impl Responder { + HttpResponse::Ok().body(r#" + Welcome to Actix-web with SQLx Todos example. + Available routes: + GET /todos -> list of all todos + POST /todo -> create new todo, example: { "description": "learn actix and sqlx", "done": false } + GET /todo/{id} -> show one todo with requested id + PUT /todo/{id} -> update todo with requested id, example: { "description": "learn actix and sqlx", "done": true } + DELETE /todo/{id} -> delete todo with requested id + "# + ) +} + +#[actix_rt::main] +async fn main() -> Result<()> { + dotenv().ok(); + env_logger::init(); + + // this will enable us to keep application running during recompile: systemfd --no-pid -s http::5000 -- cargo watch -x run + let mut listenfd = ListenFd::from_env(); + + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + // PgPool::builder() + // .max_size(5) // maximum number of connections in the pool + // .build(env::var("DATABASE_URL")?).await?; + let db_pool = PgPool::new(&database_url).await?; + + let mut server = HttpServer::new(move || { + App::new() + .data(db_pool.clone()) // pass database pool to application so we can access it inside handlers + .route("/", web::get().to(index)) + .configure(todo::init) // init todo routes + }); + + server = match listenfd.take_tcp_listener(0)? { + Some(listener) => server.listen(listener)?, + None => { + 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"); + server.bind(format!("{}:{}", host, port))? + } + }; + + info!("Starting server"); + server.run().await?; + + Ok(()) +} + +// export DATABASE_URL="postgres://pguser:zx@192.168.33.11/realworld" +// systemfd --no-pid -s http::5000 -- cargo watch -x run +// I would add the example as "todo-api" +// Under the postgres folder \ No newline at end of file diff --git a/examples/postgres/todo-api/src/todo/mod.rs b/examples/postgres/todo-api/src/todo/mod.rs new file mode 100644 index 00000000..61f924b1 --- /dev/null +++ b/examples/postgres/todo-api/src/todo/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod routes; + +pub use model::*; +pub use routes::init; \ No newline at end of file diff --git a/examples/postgres/todo-api/src/todo/model.rs b/examples/postgres/todo-api/src/todo/model.rs new file mode 100644 index 00000000..bc96b001 --- /dev/null +++ b/examples/postgres/todo-api/src/todo/model.rs @@ -0,0 +1,130 @@ +use serde::{Serialize, Deserialize}; +use actix_web::{HttpResponse, HttpRequest, Responder, Error}; +use futures::future::{ready, Ready}; +use sqlx::{PgPool, FromRow, Row}; +use sqlx::postgres::PgRow; +use anyhow::Result; + +// this struct will use to receive user input +#[derive(Serialize, Deserialize)] +pub struct TodoRequest { + pub description: String, + pub done: bool +} + +// this struct will be used to represent database record +#[derive(Serialize, FromRow)] +pub struct Todo { + pub id: i32, + pub description: String, + pub done: bool, +} + +// implementation of Actix Responder for Todo struct so we can return Todo from action handler +impl Responder for Todo { + type Error = Error; + type Future = Ready>; + + fn respond_to(self, _req: &HttpRequest) -> Self::Future { + let body = serde_json::to_string(&self).unwrap(); + // create response and set content type + ready(Ok( + HttpResponse::Ok() + .content_type("application/json") + .body(body) + )) + } +} + +// Implementation for Todo struct, functions for read/write/update and delete todo from database +impl Todo { + pub async fn find_all(pool: &PgPool) -> Result> { + let mut todos = vec![]; + let recs = sqlx::query!( + r#" + SELECT id, description, done + FROM todos + ORDER BY id + "# + ) + .fetch_all(pool) + .await?; + + for rec in recs { + todos.push(Todo { + id: rec.id, + description: rec.description, + done: rec.done + }); + } + + Ok(todos) + } + + pub async fn find_by_id(id: i32, pool: &PgPool) -> Result { + let rec = sqlx::query!( + r#" + SELECT * FROM todos WHERE id = $1 + "#, + id + ) + .fetch_one(&*pool) + .await?; + + Ok(Todo { + id: rec.id, + description: rec.description, + done: rec.done + }) + } + + pub async fn create(todo: TodoRequest, pool: &PgPool) -> Result { + let mut tx = pool.begin().await?; + let todo = sqlx::query("INSERT INTO todos (description, done) VALUES ($1, $2) RETURNING id, description, done") + .bind(&todo.description) + .bind(todo.done) + .map(|row: PgRow| { + Todo { + id: row.get(0), + description: row.get(1), + done: row.get(2) + } + }) + .fetch_one(&mut tx) + .await?; + + tx.commit().await?; + Ok(todo) + } + + pub async fn update(id: i32, todo: TodoRequest, pool: &PgPool) -> Result { + let mut tx = pool.begin().await.unwrap(); + let todo = sqlx::query("UPDATE todos SET description = $1, done = $2 WHERE id = $3 RETURNING id, description, done") + .bind(&todo.description) + .bind(todo.done) + .bind(id) + .map(|row: PgRow| { + Todo { + id: row.get(0), + description: row.get(1), + done: row.get(2) + } + }) + .fetch_one(&mut tx) + .await?; + + tx.commit().await.unwrap(); + Ok(todo) + } + + pub async fn delete(id: i32, pool: &PgPool) -> Result { + let mut tx = pool.begin().await?; + let deleted = sqlx::query("DELETE FROM todos WHERE id = $1") + .bind(id) + .execute(&mut tx) + .await?; + + tx.commit().await?; + Ok(deleted) + } +} diff --git a/examples/postgres/todo-api/src/todo/routes.rs b/examples/postgres/todo-api/src/todo/routes.rs new file mode 100644 index 00000000..58b5a904 --- /dev/null +++ b/examples/postgres/todo-api/src/todo/routes.rs @@ -0,0 +1,63 @@ +use crate::todo::{Todo, TodoRequest}; +use actix_web::{delete, get, post, put, web, HttpResponse, Responder}; +use sqlx::PgPool; + +#[get("/todos")] +async fn find_all(db_pool: web::Data) -> impl Responder { + let result = Todo::find_all(db_pool.get_ref()).await; + match result { + Ok(todos) => HttpResponse::Ok().json(todos), + _ => HttpResponse::BadRequest().body("Error trying to read all todos from database") + } +} + +#[get("/todo/{id}")] +async fn find(id: web::Path, db_pool: web::Data) -> impl Responder { + let result = Todo::find_by_id(id.into_inner(), db_pool.get_ref()).await; + match result { + Ok(todo) => HttpResponse::Ok().json(todo), + _ => HttpResponse::BadRequest().body("Todo not found") + } +} + +#[post("/todo")] +async fn create(todo: web::Json, db_pool: web::Data) -> impl Responder { + let result = Todo::create(todo.into_inner(), db_pool.get_ref()).await; + match result { + Ok(todo) => HttpResponse::Ok().json(todo), + _ => HttpResponse::BadRequest().body("Error trying to create new todo") + } +} + +#[put("/todo/{id}")] +async fn update(id: web::Path, todo: web::Json, db_pool: web::Data) -> impl Responder { + let result = Todo::update(id.into_inner(), todo.into_inner(),db_pool.get_ref()).await; + match result { + Ok(todo) => HttpResponse::Ok().json(todo), + _ => HttpResponse::BadRequest().body("Todo not found") + } +} + +#[delete("/todo/{id}")] +async fn delete(id: web::Path, db_pool: web::Data) -> impl Responder { + let result = Todo::delete(id.into_inner(), db_pool.get_ref()).await; + match result { + Ok(rows) => { + if rows > 0 { + HttpResponse::Ok().body(format!("Successfully deleted {} record(s)", rows)) + } else { + HttpResponse::BadRequest().body("Todo not found") + } + }, + _ => HttpResponse::BadRequest().body("Todo not found") + } +} + +// function that will be called on new Application to configure routes for this module +pub fn init(cfg: &mut web::ServiceConfig) { + cfg.service(find_all); + cfg.service(find); + cfg.service(create); + cfg.service(update); + cfg.service(delete); +} \ No newline at end of file