GraphQL is a query language for reading and manipulating data for APIs. It prioritizes giving clients or servers the exact data requirement by providing a flexible and intuitive syntax to describe such data.
Compared to a traditional REST API, GraphQL provides a type system to describe schemas for data and, in turn, gives consumers of the API the affordance to explore and request the needed data using a single endpoint.
This post will discuss building a project management application with Rust using the Async-graphql library and MongoDB. At the end of this tutorial, we will learn how to create a GraphQL endpoint that supports reading and manipulating project management data and persisting our data using MongoDB.
GitHub repository can be found here.
Prerequisites
To fully grasp the concepts presented in this tutorial, experience with Rust is required. Experience with MongoDB isn’t a requirement, but it’s nice to have.
We will also be needing the following:
- Basic knowledge of GraphQL
- A MongoDB account to host the database. Signup is completely free
Let’s code
Getting Started
To get started, we need to navigate to the desired directory and run the command below in our terminal
cargo new project-mngt-rust-graphql-rocket && cd project-mngt-rust-graphql-rocket
This command creates a Rust project called project-mngt-rust-graphql-rocket
and navigates into the project directory.
Next, we proceed to install the required dependencies by modifying the [dependencies]
section of the Cargo.toml
file as shown below:
//other code section goes here
[dependencies]
rocket = {version = "0.5.0-rc.2", features = ["json"]}
async-graphql = { version = "4.0", features = ["bson", "chrono"] }
async-graphql-rocket = "4.0.0"
serde = "1.0.136"
dotenv = "0.15.0"
[dependencies.mongodb]
version = "2.2.0"
default-features = false
features = ["sync"]
rocket = {version = "0.5.0-rc.2", features = ["json"]}
is a Rust-based framework for building web applications. It also specifies the required version and the feature type(json).
async-graphql = { version = "4.0", features = ["bson", "chrono"] }
is a server-side library for building GraphQL in Rust. It also features bson
and chrono
.
async-graphql-rocket = "4.0"
is a library that helps integrate async-grapql
with Rocket
.
serde = "1.0.136"
is a framework for serializing and deserializing Rust data structures. E.g. convert Rust structs to JSON.
dotenv = "0.15.0"
is a library for managing environment variables.
[dependencies.mongodb]
is a driver for connecting to MongoDB. It also specifies the required version and the feature type(Sync API).
We need to run the command below to install the dependencies:
cargo build
Module system in Rust
Modules are like folder structures in our application; they simplify how we manage dependencies.
To do this, we need to navigate to the src
folder and create the config
, handler
, and schemas
folder with their corresponding mod.rs
file to manage visibility.
config
is for modularizing configuration files.
handler
is for modularizing GraphQL logics.
schemas
is for modularizing GraphQL schema.
Adding a reference to the Modules
To use the code in the modules, we need to declare them as a module and import them into the main.rs
file.
//add modules
mod config;
mod handler;
mod schemas;
fn main() {
println!("Hello, world!");
}
Setting up MongoDB
With that done, we need to log in or sign up into our MongoDB account. Click the project dropdown menu and click on the New Project button.
Enter the projectMngt
as the project name, click Next, and click Create Project..
Click on Build a Database
Select Shared as the type of database.
Click on Create to setup a cluster. This might take sometime to setup.
Next, we need to create a user to access the database externally by inputting the Username, Password and then clicking on Create User. We also need to add our IP address to safely connect to the database by clicking on the Add My Current IP Address button. Then click on Finish and Close to save changes.
On saving the changes, we should see a Database Deployments screen, as shown below:
Connecting our application to MongoDB
With the configuration done, we need to connect our application with the database created. To do this, click on the Connect button
Click on Connect your application, change the Driver to Rust
and the Version as shown below. Then click on the copy icon to copy the connection string.
Setup Environment Variable
Next, we must modify the copied connection string with the user's password we created earlier and change the database name. To do this, first, we need to create a .env
file in the root directory, and in this file, add the snippet below:
MONGOURI=mongodb+srv://<YOUR USERNAME HERE>:<YOUR PASSWORD HERE>@cluster0.e5akf.mongodb.net/<DATABASE NAME>?retryWrites=true&w=majority
Sample of a properly filled connection string below:
MONGOURI=mongodb+srv://malomz:malomzPassword@cluster0.e5ahghkf.mongodb.net/projectMngt?retryWrites=true&w=majority
Creating GraphQL Endpoints
With the setup done, we need to create a schema to represent our application data. To do this, we need to navigate to the schemas
folder, and in this folder, create a project_schema.rs
file and add the snippet below:
use async_graphql::{Enum, InputObject, SimpleObject};
use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
//owner schema
#[derive(Debug, Clone, Serialize, Deserialize, SimpleObject)]
pub struct Owner {
#[serde(skip_serializing_if = "Option::is_none")]
pub _id: Option<ObjectId>,
pub name: String,
pub email: String,
pub phone: String,
}
#[derive(InputObject)]
pub struct CreateOwner {
pub name: String,
pub email: String,
pub phone: String,
}
#[derive(InputObject)]
pub struct FetchOwner {
pub _id: String,
}
//project schema
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Enum)]
pub enum Status {
NotStarted,
InProgress,
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize, SimpleObject)]
pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")]
pub _id: Option<ObjectId>,
pub owner_id: String,
pub name: String,
pub description: String,
pub status: Status,
}
#[derive(InputObject)]
pub struct CreateProject {
pub owner_id: String,
pub name: String,
pub description: String,
pub status: Status,
}
#[derive(InputObject)]
pub struct FetchProject {
pub _id: String,
}
The snippet above does the following:
- Imports required dependencies
- Uses the
derive
macro to generate implementation support forOwner
,CreateOwner
,FetchOwner
,Status
,Project
,CreateProject
, andFetchProject
. The snippet also uses the procedural macro from theserde
andasync-graphql
library to serialize/deserialize and convert Rust structs to a GraphQL schema.
Next, we must register the project_schema.rs
file as part of the schemas
module. To do this, open the mod.rs
in the schemas
folder and add the snippet below:
pub mod project_schema;
Database Logic
With the schema fully set up and made available to be consumed, we can now create our database logic that will do the following:
- Create project owner
- Get all owners
- Get a single owner
- Create project
- Get all projects
- Get a single project
To do this, First, we need to navigate to the config
folder, and in this folder, create a mongo.rs
file and add the snippet below:
use dotenv::dotenv;
use std::{env, io::Error};
use mongodb::{
bson::{doc, oid::ObjectId},
sync::{Client, Collection, Database},
};
use crate::schemas::project_schema::{Owner, Project};
pub struct DBMongo {
db: Database,
}
impl DBMongo {
pub fn init() -> Self {
dotenv().ok();
let uri = match env::var("MONGOURI") {
Ok(v) => v.to_string(),
Err(_) => format!("Error loading env variable"),
};
let client = Client::with_uri_str(uri).unwrap();
let db = client.database("projectMngt");
DBMongo { db }
}
fn col_helper<T>(data_source: &Self, collection_name: &str) -> Collection<T> {
data_source.db.collection(collection_name)
}
}
The snippet above does the following:
- Imports the required dependencies
- Creates a
DBMongo
struct with adb
field to access MongoDB database - Creates an implementation block that adds methods to the
DBMongo
struct - Adds an
init
method to the implementation block to load the environment variable, creates a connection to the database, and returns an instance of theDBMongo
struct - Adds a
col_helper
method; a helper function to create MongoDB collection
Next, we need to add the remaining methods to the DBMongo
implementation to cater to the project management operations:
//imports goes here
pub struct DBMongo {
db: Database,
}
impl DBMongo {
pub fn init() -> Self {
//init code goes here
}
fn col_helper<T>(data_source: &Self, collection_name: &str) -> Collection<T> {
data_source.db.collection(collection_name)
}
//Owners logic
pub fn create_owner(&self, new_owner: Owner) -> Result<Owner, Error> {
let new_doc = Owner {
_id: None,
name: new_owner.name.clone(),
email: new_owner.email.clone(),
phone: new_owner.phone.clone(),
};
let col = DBMongo::col_helper::<Owner>(&self, "owner");
let data = col
.insert_one(new_doc, None)
.ok()
.expect("Error creating owner");
let new_owner = Owner {
_id: data.inserted_id.as_object_id(),
name: new_owner.name.clone(),
email: new_owner.email.clone(),
phone: new_owner.phone.clone(),
};
Ok(new_owner)
}
pub fn get_owners(&self) -> Result<Vec<Owner>, Error> {
let col = DBMongo::col_helper::<Owner>(&self, "owner");
let cursors = col
.find(None, None)
.ok()
.expect("Error getting list of owners");
let owners: Vec<Owner> = cursors.map(|doc| doc.unwrap()).collect();
Ok(owners)
}
pub fn single_owner(&self, id: &String) -> Result<Owner, Error> {
let obj_id = ObjectId::parse_str(id).unwrap();
let filter = doc! {"_id": obj_id};
let col = DBMongo::col_helper::<Owner>(&self, "owner");
let owner_detail = col
.find_one(filter, None)
.ok()
.expect("Error getting owner's detail");
Ok(owner_detail.unwrap())
}
//project logics
pub fn create_project(&self, new_project: Project) -> Result<Project, Error> {
let new_doc = Project {
_id: None,
owner_id: new_project.owner_id.clone(),
name: new_project.name.clone(),
description: new_project.description.clone(),
status: new_project.status.clone(),
};
let col = DBMongo::col_helper::<Project>(&self, "project");
let data = col
.insert_one(new_doc, None)
.ok()
.expect("Error creating project");
let new_project = Project {
_id: data.inserted_id.as_object_id(),
owner_id: new_project.owner_id.clone(),
name: new_project.name.clone(),
description: new_project.description.clone(),
status: new_project.status.clone(),
};
Ok(new_project)
}
pub fn get_projects(&self) -> Result<Vec<Project>, Error> {
let col = DBMongo::col_helper::<Project>(&self, "project");
let cursors = col
.find(None, None)
.ok()
.expect("Error getting list of projects");
let projects: Vec<Project> = cursors.map(|doc| doc.unwrap()).collect();
Ok(projects)
}
pub fn single_project(&self, id: &String) -> Result<Project, Error> {
let obj_id = ObjectId::parse_str(id).unwrap();
let filter = doc! {"_id": obj_id};
let col = DBMongo::col_helper::<Project>(&self, "project");
let project_detail = col
.find_one(filter, None)
.ok()
.expect("Error getting project's detail");
Ok(project_detail.unwrap())
}
}
The snippet above does the following:
- Adds a
create_owner
method that takes in aself
andnew_owner
as parameters and returns the created owner or an error. Inside the method, we created a new document using theOwner
struct. Then we use thecol_helper
method to create a new collection and access theinsert_one
function to create a new owner and handle errors. Finally, we returned the created owner information - Adds a
get_owners
method that takes in aself
as parameters and returns the list of owners or an error. Inside the method, we use thecol_helper
method to create a new collection and access thefind
function without any filter so that it can match all the documents inside the database and returned the list optimally using themap()
method to loop through the list of owners, and handle errors - Adds a
single_owner
method that takes in aself
andid
as parameters and returns the owner detail or an error. Inside the method, we converted theid
to anObjectId
and used it as a filter to get the matching document. Then we use thecol_helper
method to create a new collection and access thefind_one
function from the collection to get the details of the owner and handle errors - Adds a
create_project
method that takes in aself
andnew_project
as parameters and returns the created project or an error. Inside the method, we created a new document using theProject
struct. Then we use thecol_helper
method to create a new collection and access theinsert_one
function to create a new project and handle errors. Finally, we returned the created project information - Adds a
get_projects
method that takes in aself
as parameters and returns the list of projects or an error. Inside the method, we use thecol_helper
method to create a new collection and access thefind
function without any filter so that it can match all the documents inside the database and returned the list optimally using themap()
method to loop through the list of projects, and handle errors - Adds a
single_project
method that takes in aself
andid
as parameters and returns the project detail or an error. Inside the method, we converted theid
to anObjectId
and used it as a filter to get the matching document. Then we use thecol_helper
method to create a new collection and access thefind_one
function from the collection to get the details of the project and handle errors
Finally, we must register the mongo.rs
file as part of the config
module. To do this, open the mod.rs
in the config
folder and add the snippet below:
pub mod mongo;
GraphQL Handlers
With the database logic sorted out, we can start using them to create our GraphQL handlers. To do this, First, we need to navigate to the handler
folder, and in this folder, create a graphql_handler.rs
file and add the snippet below:
use crate::{
config::mongo::DBMongo,
schemas::project_schema::{
CreateOwner, CreateProject, FetchOwner, FetchProject, Owner, Project,
},
};
use async_graphql::{Context, EmptySubscription, FieldResult, Object, Schema};
pub struct Query;
#[Object(extends)]
impl Query {
//owners query
async fn owner(&self, ctx: &Context<'_>, input: FetchOwner) -> FieldResult<Owner> {
let db = &ctx.data_unchecked::<DBMongo>();
let owner = db.single_owner(&input._id).unwrap();
Ok(owner)
}
async fn get_owners(&self, ctx: &Context<'_>) -> FieldResult<Vec<Owner>> {
let db = &ctx.data_unchecked::<DBMongo>();
let owners = db.get_owners().unwrap();
Ok(owners)
}
//projects query
async fn project(&self, ctx: &Context<'_>, input: FetchProject) -> FieldResult<Project> {
let db = &ctx.data_unchecked::<DBMongo>();
let project = db.single_project(&input._id).unwrap();
Ok(project)
}
async fn get_projects(&self, ctx: &Context<'_>) -> FieldResult<Vec<Project>> {
let db = &ctx.data_unchecked::<DBMongo>();
let projects = db.get_projects().unwrap();
Ok(projects)
}
}
pub struct Mutation;
#[Object]
impl Mutation {
//owner mutation
async fn create_owner(&self, ctx: &Context<'_>, input: CreateOwner) -> FieldResult<Owner> {
let db = &ctx.data_unchecked::<DBMongo>();
let new_owner = Owner {
_id: None,
email: input.email,
name: input.name,
phone: input.phone,
};
let owner = db.create_owner(new_owner).unwrap();
Ok(owner)
}
async fn create_project(
&self,
ctx: &Context<'_>,
input: CreateProject,
) -> FieldResult<Project> {
let db = &ctx.data_unchecked::<DBMongo>();
let new_project = Project {
_id: None,
owner_id: input.owner_id,
name: input.name,
description: input.description,
status: input.status,
};
let project = db.create_project(new_project).unwrap();
Ok(project)
}
}
pub type ProjectSchema = Schema<Query, Mutation, EmptySubscription>;
The snippet above does the following:
- Imports the required dependencies
- Creates a
Query
struct with implementation methods related to querying the database using the corresponding methods from the database logic - Creates a
Mutation
struct with implementation methods related to modifying the database using the corresponding methods from the database logic. - Creates a
ProjectSchema
type to construct how our GraphQL is using theQuery
struct,Mutation
struct, andEmptySubscription
since we don’t have any subscriptions.
Creating GraphQL Server
Finally, we can start creating our GraphQL server by integrating the ProjectSchema
and MongoDB with Actix web. To do this, we need to navigate to the main.rs
file and modify it as shown below:
mod config;
mod handler;
mod schemas;
//add
use async_graphql::{
http::{playground_source, GraphQLPlaygroundConfig},
EmptySubscription, Schema,
};
use async_graphql_rocket::{GraphQLQuery, GraphQLRequest, GraphQLResponse};
use config::mongo::DBMongo;
use handler::graphql_handler::{Mutation, ProjectSchema, Query};
use rocket::{response::content, routes, State};
#[rocket::get("/graphql?<query..>")]
async fn graphql_query(schema: &State<ProjectSchema>, query: GraphQLQuery) -> GraphQLResponse {
query.execute(schema).await
}
#[rocket::post("/graphql", data = "<request>", format = "application/json")]
async fn graphql_mutation(
schema: &State<ProjectSchema>,
request: GraphQLRequest,
) -> GraphQLResponse {
request.execute(schema).await
}
#[rocket::get("/")]
async fn graphql_playground() -> content::RawHtml<String> {
content::RawHtml(playground_source(GraphQLPlaygroundConfig::new("/graphql")))
}
#[rocket::launch]
fn rocket() -> _ {
let db = DBMongo::init();
let schema = Schema::build(Query, Mutation, EmptySubscription)
.data(db)
.finish();
rocket::build().manage(schema).mount(
"/",
routes![graphql_query, graphql_mutation, graphql_playground],
)
}
The snippet above does the following:
- Imports the required dependencies
- Creates a
graphql_query
function withget
procedural macro to specify the GraphQL route and uses theProjectSchema
type to execute methods related to querying the database - Creates a
graphql_mutation
function withpost
procedural macro to specify the GraphQL route and uses theProjectSchema
type to execute methods related to modifying the database - Creates a
graphql_playground
function to create GraphiQL; a GraphQL playground we can access from a browser - Uses the
#[rocket::launch]
macro to run therocket
function that generates an application entry point and runs the server. Therocket
also function does the following:- Creates a
db
variable to establish a connection to MongoDB by calling theinit()
method and uses it to build a GraphQL data - Builds the application using the
build
function, adds theschema
to state, and configures the route to include thegraphql_query
,graphql_mutation
, andgraphql_playground
- Creates a
With that done, we can test our application by running the command below in our terminal.
cargo run
Then navigate to http://127.0.0.1:8000
on a web browser.
Conclusion
This post discussed how to modularize a Rust application, build a GraphQL server, and persist our data using MongoDB.
These resources might be helpful: