From 1f434406f385bbe37dad3ef6dfb380f56e36afa6 Mon Sep 17 00:00:00 2001 From: Dwi Sulfahnur Date: Sat, 7 Dec 2019 21:16:46 +0700 Subject: [PATCH] GraphQL Example with Juniper (#181) * actix-graphql-demo added to examples * Add actix-graphql-demo to the cargo.toml as a member --- Cargo.toml | 1 + actix-graphql-demo/.env.example | 1 + actix-graphql-demo/Cargo.toml | 26 ++++ actix-graphql-demo/README.md | 28 ++++ actix-graphql-demo/mysql-schema.sql | 77 ++++++++++ actix-graphql-demo/src/db.rs | 13 ++ actix-graphql-demo/src/handlers.rs | 44 ++++++ actix-graphql-demo/src/main.rs | 31 ++++ actix-graphql-demo/src/schemas/mod.rs | 3 + actix-graphql-demo/src/schemas/product.rs | 52 +++++++ actix-graphql-demo/src/schemas/root.rs | 165 ++++++++++++++++++++++ actix-graphql-demo/src/schemas/user.rs | 45 ++++++ 12 files changed, 486 insertions(+) create mode 100644 actix-graphql-demo/.env.example create mode 100644 actix-graphql-demo/Cargo.toml create mode 100644 actix-graphql-demo/README.md create mode 100644 actix-graphql-demo/mysql-schema.sql create mode 100644 actix-graphql-demo/src/db.rs create mode 100644 actix-graphql-demo/src/handlers.rs create mode 100644 actix-graphql-demo/src/main.rs create mode 100644 actix-graphql-demo/src/schemas/mod.rs create mode 100644 actix-graphql-demo/src/schemas/product.rs create mode 100644 actix-graphql-demo/src/schemas/root.rs create mode 100644 actix-graphql-demo/src/schemas/user.rs diff --git a/Cargo.toml b/Cargo.toml index e1db836d..d3025a15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "./", + "actix-graphql-demo", "actix_redis", "actix_todo", "async_db", diff --git a/actix-graphql-demo/.env.example b/actix-graphql-demo/.env.example new file mode 100644 index 00000000..c8f7fc65 --- /dev/null +++ b/actix-graphql-demo/.env.example @@ -0,0 +1 @@ +DATABASE_URL=mysql://user:password@127.0.0.1/dbname \ No newline at end of file diff --git a/actix-graphql-demo/Cargo.toml b/actix-graphql-demo/Cargo.toml new file mode 100644 index 00000000..311db02e --- /dev/null +++ b/actix-graphql-demo/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "actix-graphql-demo" +version = "0.1.0" +authors = ["Dwi Sulfahnur "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "1.0" +futures = "0.1" + +juniper = "0.13" + +mysql = "16.1.0" +r2d2 = "0.8" +r2d2_mysql = "16.0" + +log = "0.4" +env_logger = "0.6.2" +dotenv = "0.9" +serde = "1.0" +serde_json = "1.0" +serde_derive = "1.0" + +uuid = {"version" = "0.7", "features" = ["serde", "v4"]} \ No newline at end of file diff --git a/actix-graphql-demo/README.md b/actix-graphql-demo/README.md new file mode 100644 index 00000000..9527d912 --- /dev/null +++ b/actix-graphql-demo/README.md @@ -0,0 +1,28 @@ +# actix-graphql-demo + +GraphQL Implementation in Rust using Actix, Juniper, and Mysql as Database + +# Prerequites +- Rust Installed +- MySql as Database + +# Database Configuration + +Create a new database for this project, and import the existing database schema has been provided named ```mysql-schema.sql```. + +Create ```.env``` file on the root directory of this project and set environment variable named ```DATABASE_URL```, the example file has been provided named ```.env.example```, you can see the format on there. + +# Run + + +```sh +# go to the root dir +cd actix-graphql-demo + +# Run +cargo run +``` + +### GraphQL Playground + +http://127.0.0.1:8080/graphiql diff --git a/actix-graphql-demo/mysql-schema.sql b/actix-graphql-demo/mysql-schema.sql new file mode 100644 index 00000000..a20e3152 --- /dev/null +++ b/actix-graphql-demo/mysql-schema.sql @@ -0,0 +1,77 @@ +-- MySQL dump 10.13 Distrib 8.0.16, for osx10.14 (x86_64) +-- +-- Host: 127.0.0.1 Database: rust_graphql +-- ------------------------------------------------------ +-- Server version 8.0.15 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; + SET NAMES utf8mb4 ; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `product` +-- + +DROP TABLE IF EXISTS `product`; +/*!40101 SET @saved_cs_client = @@character_set_client */; + SET character_set_client = utf8mb4 ; +CREATE TABLE `product` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `price` decimal(10,0) NOT NULL, + PRIMARY KEY (`id`), + KEY `product_fk0` (`user_id`), + CONSTRAINT `product_fk0` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `product` +-- + +LOCK TABLES `product` WRITE; +/*!40000 ALTER TABLE `product` DISABLE KEYS */; +/*!40000 ALTER TABLE `product` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `user` +-- + +DROP TABLE IF EXISTS `user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; + SET character_set_client = utf8mb4 ; +CREATE TABLE `user` ( + `id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `email` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `user` +-- + +LOCK TABLES `user` WRITE; +/*!40000 ALTER TABLE `user` DISABLE KEYS */; +/*!40000 ALTER TABLE `user` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; diff --git a/actix-graphql-demo/src/db.rs b/actix-graphql-demo/src/db.rs new file mode 100644 index 00000000..7924fce8 --- /dev/null +++ b/actix-graphql-demo/src/db.rs @@ -0,0 +1,13 @@ +use r2d2; +use r2d2_mysql::mysql::{Opts, OptsBuilder}; +use r2d2_mysql::MysqlConnectionManager; + +pub type Pool = r2d2::Pool; + +pub fn get_db_pool() -> Pool { + let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let opts = Opts::from_url(&db_url).unwrap(); + let builder = OptsBuilder::from_opts(opts); + let manager = MysqlConnectionManager::new(builder); + r2d2::Pool::new(manager).expect("Failed to create DB Pool") +} diff --git a/actix-graphql-demo/src/handlers.rs b/actix-graphql-demo/src/handlers.rs new file mode 100644 index 00000000..29372d07 --- /dev/null +++ b/actix-graphql-demo/src/handlers.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use actix_web::{Error, HttpResponse, web}; +use futures::Future; +use juniper::http::graphiql::graphiql_source; +use juniper::http::GraphQLRequest; + +use crate::db::Pool; +use crate::schemas::root::{Context, create_schema, Schema}; + +pub fn graphql( + pool: web::Data, + schema: web::Data>, + data: web::Json, +) -> impl Future { + let ctx = Context { + dbpool: pool.get_ref().to_owned(), + }; + web::block(move || { + let res = data.execute(&schema, &ctx); + Ok::<_, serde_json::error::Error>(serde_json::to_string(&res)?) + }) + .map_err(Error::from) + .and_then(|res| { + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(res)) + }) +} + +pub fn graphql_playground() -> HttpResponse { + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(graphiql_source("/graphql")) +} + + +pub fn register(config: &mut web::ServiceConfig) { + let schema = std::sync::Arc::new(create_schema()); + config + .data(schema) + .route("/graphql", web::post().to_async(graphql)) + .route("/graphiql", web::get().to(graphql_playground)); +} \ No newline at end of file diff --git a/actix-graphql-demo/src/main.rs b/actix-graphql-demo/src/main.rs new file mode 100644 index 00000000..9f0ab66e --- /dev/null +++ b/actix-graphql-demo/src/main.rs @@ -0,0 +1,31 @@ +#[macro_use] +extern crate juniper; +extern crate r2d2; +extern crate r2d2_mysql; +extern crate serde_json; + +use actix_web::{App, HttpServer, middleware, web}; + +use crate::db::get_db_pool; +use crate::handlers::{register}; + +mod handlers; +mod schemas; +mod db; + +fn main() -> std::io::Result<()> { + dotenv::dotenv().ok(); + std::env::set_var("RUST_LOG", "actix_web=info,info"); + env_logger::init(); + let pool = get_db_pool(); + + HttpServer::new(move || { + App::new() + .data(pool.clone()) + .wrap(middleware::Logger::default()) + .configure(register) + .default_service(web::to(|| "404")) + }) + .bind("127.0.0.1:8080")? + .run() +} \ No newline at end of file diff --git a/actix-graphql-demo/src/schemas/mod.rs b/actix-graphql-demo/src/schemas/mod.rs new file mode 100644 index 00000000..b0647329 --- /dev/null +++ b/actix-graphql-demo/src/schemas/mod.rs @@ -0,0 +1,3 @@ +pub mod root; +pub mod user; +pub mod product; diff --git a/actix-graphql-demo/src/schemas/product.rs b/actix-graphql-demo/src/schemas/product.rs new file mode 100644 index 00000000..da0bb8dc --- /dev/null +++ b/actix-graphql-demo/src/schemas/product.rs @@ -0,0 +1,52 @@ +use juniper; +use mysql::{Error as DBError, from_row, params, Row}; + +use crate::schemas::root::Context; +use crate::schemas::user::User; + +/// Product +#[derive(Default, Debug)] +pub struct Product { + pub id: String, + pub user_id: String, + pub name: String, + pub price: f64, +} + +#[juniper::object(Context = Context)] +impl Product { + fn id(&self) -> &str { + &self.id + } + fn user_id(&self) -> &str { + &self.user_id + } + fn name(&self) -> &str { + &self.name + } + fn price(&self) -> f64 { + self.price + } + + fn user(&self, context: &Context) -> Option { + let mut conn = context.dbpool.get().unwrap(); + let user: Result, DBError> = conn.first_exec( + "SELECT * FROM user WHERE id=:id", + params! {"id" => &self.user_id}, + ); + if let Err(err) = user { + None + }else{ + let (id, name, email) = from_row(user.unwrap().unwrap()); + Some(User { id, name, email }) + } + } +} + +#[derive(GraphQLInputObject)] +#[graphql(description = "Product Input")] +pub struct ProductInput { + pub user_id: String, + pub name: String, + pub price: f64, +} \ No newline at end of file diff --git a/actix-graphql-demo/src/schemas/root.rs b/actix-graphql-demo/src/schemas/root.rs new file mode 100644 index 00000000..ef223e28 --- /dev/null +++ b/actix-graphql-demo/src/schemas/root.rs @@ -0,0 +1,165 @@ +use juniper::{FieldError, FieldResult, RootNode}; +use juniper; +use mysql::{Error as DBError, from_row, params, Row}; + +use crate::db::Pool; + +use super::product::{Product, ProductInput}; +use super::user::{User, UserInput}; + +pub struct Context { + pub dbpool: Pool +} + +impl juniper::Context for Context {} + +pub struct QueryRoot; + +#[juniper::object(Context = Context)] +impl QueryRoot { + #[graphql(description = "List of all users")] + fn users(context: &Context) -> FieldResult> { + let mut conn = context.dbpool.get().unwrap(); + let users = conn.prep_exec("select * from user", ()) + .map(|result| { + result.map(|x| x.unwrap()).map(|mut row| { + let (id, name, email) = from_row(row); + User { id, name, email } + }).collect() + }).unwrap(); + Ok(users) + } + + #[graphql(description = "Get Single user reference by user ID")] + fn user(context: &Context, id: String) -> FieldResult { + let mut conn = context.dbpool.get().unwrap(); + + let user: Result, DBError> = conn.first_exec( + "SELECT * FROM user WHERE id=:id", + params! {"id" => id}, + ); + + if let Err(err) = user { + return Err(FieldError::new( + "User Not Found", + graphql_value!({ "not_found": "user not found" }), + )); + } + + let (id, name, email) = from_row(user.unwrap().unwrap()); + Ok(User { id, name, email }) + } + + #[graphql(description = "List of all users")] + fn products(context: &Context) -> FieldResult> { + let mut conn = context.dbpool.get().unwrap(); + let products = conn.prep_exec("select * from product", ()) + .map(|result| { + result.map(|x| x.unwrap()).map(|mut row| { + let (id, user_id, name, price) = from_row(row); + Product { id, user_id, name, price } + }).collect() + }).unwrap(); + Ok(products) + } + + #[graphql(description = "Get Single user reference by user ID")] + fn product(context: &Context, id: String) -> FieldResult { + let mut conn = context.dbpool.get().unwrap(); + let product: Result, DBError> = conn.first_exec( + "SELECT * FROM user WHERE id=:id", + params! {"id" => id}, + ); + if let Err(err) = product { + return Err(FieldError::new( + "Product Not Found", + graphql_value!({ "not_found": "product not found" }), + )); + } + + let (id, user_id, name, price) = from_row(product.unwrap().unwrap()); + Ok(Product { id, user_id, name, price }) + } +} + + +pub struct MutationRoot; + +#[juniper::object(Context = Context)] +impl MutationRoot { + fn create_user(context: &Context, user: UserInput) -> FieldResult { + let mut conn = context.dbpool.get().unwrap(); + let new_id = uuid::Uuid::new_v4().to_simple().to_string(); + + let insert: Result, DBError> = conn.first_exec( + "INSERT INTO user(id, name, email) VALUES(:id, :name, :email)", + params! { + "id" => &new_id.to_owned(), + "name" => &user.name.to_owned(), + "email" => &user.email.to_owned(), + }, + ); + + match insert { + Ok(opt_row) => { + Ok(User { + id: new_id, + name: user.name, + email: user.email, + }) + } + Err(err) => { + let msg = match err { + DBError::MySqlError(err) => err.message, + _ => "internal error".to_owned() + }; + Err(FieldError::new( + "Failed to create new user", + graphql_value!({ "internal_error": msg }), + )) + } + } + } + + fn create_product(context: &Context, product: ProductInput) -> FieldResult { + let mut conn = context.dbpool.get().unwrap(); + let new_id = uuid::Uuid::new_v4().to_simple().to_string(); + + let insert: Result, DBError> = conn.first_exec( + "INSERT INTO product(id, user_id, name, price) VALUES(:id, :user_id, :name, :price)", + params! { + "id" => &new_id.to_owned(), + "user_id" => &product.user_id.to_owned(), + "name" => &product.name.to_owned(), + "price" => &product.price.to_owned(), + }, + ); + + match insert { + Ok(opt_row) => { + Ok(Product { + id: new_id, + user_id: product.user_id, + name: product.name, + price: product.price, + }) + } + Err(err) => { + let msg = match err { + DBError::MySqlError(err) => err.message, + _ => "internal error".to_owned() + }; + Err(FieldError::new( + "Failed to create new product", + graphql_value!({ "internal_error": msg }), + )) + } + } + } +} + +pub type Schema = RootNode<'static, QueryRoot, MutationRoot>; + +pub fn create_schema() -> Schema { + Schema::new(QueryRoot, MutationRoot) +} diff --git a/actix-graphql-demo/src/schemas/user.rs b/actix-graphql-demo/src/schemas/user.rs new file mode 100644 index 00000000..120bd17c --- /dev/null +++ b/actix-graphql-demo/src/schemas/user.rs @@ -0,0 +1,45 @@ +use juniper; +use mysql::{from_row, params}; + +use crate::schemas::product::Product; +use crate::schemas::root::Context; + +/// User +#[derive(Default, Debug)] +pub struct User { + pub id: String, + pub name: String, + pub email: String, +} + +#[derive(GraphQLInputObject)] +#[graphql(description = "User Input")] +pub struct UserInput { + pub name: String, + pub email: String, +} + + +#[juniper::object(Context = Context)] +impl User { + fn id(&self) -> &str { &self.id } + fn name(&self) -> &str { + &self.name + } + fn email(&self) -> &str { &self.email } + + fn products(&self, context: &Context) -> Vec { + let mut conn = context.dbpool.get().unwrap(); + let products = conn.prep_exec( + "select * from product where user_id=:user_id", params! { + "user_id" => &self.id + }) + .map(|result| { + result.map(|x| x.unwrap()).map(|mut row| { + let (id, user_id, name, price) = from_row(row); + Product { id, user_id, name, price } + }).collect() + }).unwrap(); + products + } +}