From 8dab533b40d9d0640e5c75922c9e8e292ed4a7d5 Mon Sep 17 00:00:00 2001 From: Milan Zivkovic Date: Thu, 23 Apr 2020 10:03:04 +0200 Subject: [PATCH] Simple Todo API using actix-web and sqlx with postgresql database (#303) * Simple Todo application using Actix-web with SQLx and PostgreSQL * fix issue with not matching project name * remove comment * - downgrade on actix-web 2.0.0 - fixed typo - renamed .env-example to .env.example - removed comments --- sqlx_todo/.env.example | 4 ++ sqlx_todo/.gitignore | 2 + sqlx_todo/Cargo.toml | 20 ++++++ sqlx_todo/README.md | 33 +++++++++ sqlx_todo/schema.sql | 5 ++ sqlx_todo/src/main.rs | 59 ++++++++++++++++ sqlx_todo/src/todo/mod.rs | 5 ++ sqlx_todo/src/todo/model.rs | 130 +++++++++++++++++++++++++++++++++++ sqlx_todo/src/todo/routes.rs | 63 +++++++++++++++++ 9 files changed, 321 insertions(+) create mode 100644 sqlx_todo/.env.example create mode 100644 sqlx_todo/.gitignore create mode 100644 sqlx_todo/Cargo.toml create mode 100644 sqlx_todo/README.md create mode 100644 sqlx_todo/schema.sql create mode 100644 sqlx_todo/src/main.rs create mode 100644 sqlx_todo/src/todo/mod.rs create mode 100644 sqlx_todo/src/todo/model.rs create mode 100644 sqlx_todo/src/todo/routes.rs diff --git a/sqlx_todo/.env.example b/sqlx_todo/.env.example new file mode 100644 index 00000000..b2ee2973 --- /dev/null +++ b/sqlx_todo/.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=sqlx_todo=info,actix=info diff --git a/sqlx_todo/.gitignore b/sqlx_todo/.gitignore new file mode 100644 index 00000000..fedaa2b1 --- /dev/null +++ b/sqlx_todo/.gitignore @@ -0,0 +1,2 @@ +/target +.env diff --git a/sqlx_todo/Cargo.toml b/sqlx_todo/Cargo.toml new file mode 100644 index 00000000..954a1e20 --- /dev/null +++ b/sqlx_todo/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "sqlx_todo" +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 = "2.0.0" +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/sqlx_todo/README.md b/sqlx_todo/README.md new file mode 100644 index 00000000..bf95ffbe --- /dev/null +++ b/sqlx_todo/README.md @@ -0,0 +1,33 @@ +# actix-sqlx-todo + +Example Todo application using Actix-web and [SQLx](https://github.com/launchbadge/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/sqlx_todo +``` + +## 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/sqlx_todo/schema.sql b/sqlx_todo/schema.sql new file mode 100644 index 00000000..dd9dc7ff --- /dev/null +++ b/sqlx_todo/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/sqlx_todo/src/main.rs b/sqlx_todo/src/main.rs new file mode 100644 index 00000000..1a0f147f --- /dev/null +++ b/sqlx_todo/src/main.rs @@ -0,0 +1,59 @@ +#[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"); + 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(()) +} \ No newline at end of file diff --git a/sqlx_todo/src/todo/mod.rs b/sqlx_todo/src/todo/mod.rs new file mode 100644 index 00000000..61f924b1 --- /dev/null +++ b/sqlx_todo/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/sqlx_todo/src/todo/model.rs b/sqlx_todo/src/todo/model.rs new file mode 100644 index 00000000..bc96b001 --- /dev/null +++ b/sqlx_todo/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/sqlx_todo/src/todo/routes.rs b/sqlx_todo/src/todo/routes.rs new file mode 100644 index 00000000..58b5a904 --- /dev/null +++ b/sqlx_todo/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