Create a GraphQL-powered project management endpoint in Rust and MongoDB - Actix web version

malomz

Demola Malomo

Posted on July 25, 2022

Create a GraphQL-powered project management endpoint in Rust and MongoDB - Actix web version

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:

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-actix && cd project-mngt-rust-graphql-actix
Enter fullscreen mode Exit fullscreen mode

This command creates a Rust project called project-mngt-rust-graphql-actix 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]
    actix-web = "4"
    async-graphql = { version = "4.0", features = ["bson", "chrono"] }
    async-graphql-actix-web = "4.0"
    serde = "1.0.136"
    dotenv = "0.15.0"
    futures = "0.3"

    [dependencies.mongodb]
    version = "2.2.0"
Enter fullscreen mode Exit fullscreen mode

actix-web = "4" is a Rust based framework for building web application.

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-actix-web = "4.0" is a library that helps integrate async-grapql with actix web.

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.

futures = "0.3" is a library for doing asynchronous programming in rust.

[dependencies.mongodb] is a driver for connecting to MongoDB. It also specifies the required version.

We need to run the command below to install the dependencies:

    cargo build
Enter fullscreen mode Exit fullscreen mode

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.

Updated project folder structure

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!");
    }
Enter fullscreen mode Exit fullscreen mode

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.

New Project

Enter the projectMngt as the project name, click Next, and click Create Project..

enter project name
Create Project

Click on Build a Database

Select Shared as the type of database.

Shared highlighted in red

Click on Create to setup a cluster. This might take sometime to setup.

Creating a cluster

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.

Create user
Add IP

On saving the changes, we should see a Database Deployments screen, as shown below:

Database Screen

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

Connect to database

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.

connect application
Copy 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
Enter fullscreen mode Exit fullscreen mode

Sample of a properly filled connection string below:

   MONGOURI=mongodb+srv://malomz:malomzPassword@cluster0.e5ahghkf.mongodb.net/projectMngt?retryWrites=true&w=majority
Enter fullscreen mode Exit fullscreen mode

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,
    }
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports required dependencies
  • Uses the derive macro to generate implementation support for Owner, CreateOwner, FetchOwner, Status, Project, CreateProject, and FetchProject. The snippet also uses the procedural macro from the serde and async-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;
Enter fullscreen mode Exit fullscreen mode

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 futures::TryStreamExt;
    use std::{env, io::Error};
    use mongodb::{
        bson::{doc, oid::ObjectId},
        Client, Collection, Database,
    };
    use crate::schemas::project_schema::{Owner, Project};

    pub struct DBMongo {
        db: Database,
    }

    impl DBMongo {
        pub async 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)
                .await
                .expect("error connecting to database");
            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)
        }
    }
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependencies
  • Creates a DBMongo struct with a db 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 the DBMongo 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 async 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 async 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)
                .await
                .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 async fn get_owners(&self) -> Result<Vec<Owner>, Error> {
            let col = DBMongo::col_helper::<Owner>(&self, "owner");
            let mut cursors = col
                .find(None, None)
                .await
                .expect("Error getting list of owners");
            let mut owners: Vec<Owner> = Vec::new();
            while let Some(owner) = cursors
                .try_next()
                .await
                .expect("Error mapping through cursor")
            {
                owners.push(owner)
            }
            Ok(owners)
        }

        pub async 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)
                .await
                .expect("Error getting owner's detail");
            Ok(owner_detail.unwrap())
        }

        //project logics
        pub async 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)
                .await
                .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 async fn get_projects(&self) -> Result<Vec<Project>, Error> {
            let col = DBMongo::col_helper::<Project>(&self, "project");
            let mut cursors = col
                .find(None, None)
                .await
                .expect("Error getting list of projects");
            let mut projects: Vec<Project> = Vec::new();
            while let Some(project) = cursors
                .try_next()
                .await
                .expect("Error mapping through cursor")
            {
                projects.push(project)
            }
            Ok(projects)
        }

        pub async 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)
                .await
                .expect("Error getting project's detail");
            Ok(project_detail.unwrap())
        }
    }
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Adds a create_owner method that takes in a self and new_owner as parameters and returns the created owner or an error. Inside the method, we created a new document using the Owner struct. Then we use the col_helper method to create a new collection and access the insert_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 a self as parameters and returns the list of owners or an error. Inside the method, we use the col_helper method to create a new collection and access the find function without any filter so that it can match all the documents inside the database and returned the list optimally using the try_next() method to loop through the list of owners, and handle errors
  • Adds a single_owner method that takes in a self and id as parameters and returns the owner detail or an error. Inside the method, we converted the id to an ObjectId and used it as a filter to get the matching document. Then we use the col_helper method to create a new collection and access the find_one function from the collection to get the details of the owner and handle errors
  • Adds a create_project method that takes in a self and new_project as parameters and returns the created project or an error. Inside the method, we created a new document using the Project struct. Then we use the col_helper method to create a new collection and access the insert_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 a self as parameters and returns the list of projects or an error. Inside the method, we use the col_helper method to create a new collection and access the find function without any filter so that it can match all the documents inside the database and returned the list optimally using the try_next() method to loop through the list of projects, and handle errors
  • Adds a single_project method that takes in a self and id as parameters and returns the project detail or an error. Inside the method, we converted the id to an ObjectId and used it as a filter to get the matching document. Then we use the col_helper method to create a new collection and access the find_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;
Enter fullscreen mode Exit fullscreen mode

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).await.unwrap();
            Ok(owner)
        }

        async fn get_owners(&self, ctx: &Context<'_>) -> FieldResult<Vec<Owner>> {
            let db = &ctx.data_unchecked::<DBMongo>();
            let owners = db.get_owners().await.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).await.unwrap();
            Ok(project)
        }

        async fn get_projects(&self, ctx: &Context<'_>) -> FieldResult<Vec<Project>> {
            let db = &ctx.data_unchecked::<DBMongo>();
            let projects = db.get_projects().await.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).await.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).await.unwrap();
            Ok(project)
        }
    }

    pub type ProjectSchema = Schema<Query, Mutation, EmptySubscription>;
Enter fullscreen mode Exit fullscreen mode

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 the Query struct, Mutation struct, and EmptySubscription 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 actix_web::{
        guard,
        web::{self, Data},
        App, HttpResponse, HttpServer,
    };
    use async_graphql::{
        http::{playground_source, GraphQLPlaygroundConfig},
        EmptySubscription, Schema,
    };
    use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse};
    use config::mongo::DBMongo;
    use handler::graphql_handler::{Mutation, ProjectSchema, Query};


    //graphql entry
    async fn index(schema: Data<ProjectSchema>, req: GraphQLRequest) -> GraphQLResponse {
        schema.execute(req.into_inner()).await.into()
    }

    async fn graphql_playground() -> HttpResponse {
        HttpResponse::Ok()
            .content_type("text/html; charset=utf-8")
            .body(playground_source(GraphQLPlaygroundConfig::new("/")))
    }

    #[actix_web::main]
    async fn main() -> std::io::Result<()> {
        //connect to the data source
        let db = DBMongo::init().await;
        let schema_data = Schema::build(Query, Mutation, EmptySubscription)
            .data(db)
            .finish();
        HttpServer::new(move || {
            App::new()
                .app_data(Data::new(schema_data.clone()))
                .service(web::resource("/").guard(guard::Post()).to(index))
                .service(
                    web::resource("/")
                        .guard(guard::Get())
                        .to(graphql_playground),
                )
        })
        .bind(("127.0.0.1", 8080))?
        .run()
        .await
    }
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependencies
  • Creates an index function that uses the ProjectSchema type to create a GraphQL server
  • Creates a graphql_playground function to create GraphiQL; a GraphQL playground we can access from a browser
  • Uses the #[actix_web::main] macro to run the main function asynchronously within the actix runtime. The main function also does the following:
    • Creates a db variable to establish a connection to MongoDB by calling the init() method and uses it to build a GraphQL data
    • Creates a new server using HttpServer struct that uses a closure to serve incoming requests using the App instance that accepts the GraphQL data, add a Post service to manage all incoming GraphQL requests and a Get method to render the GraphiQL playground
    • Configures the server to run asynchronously and process HTTP requests on localhost:8080.

With that done, we can test our application by running the command below in our terminal.

    cargo run
Enter fullscreen mode Exit fullscreen mode

Then navigate to 127.0.0.1:8080 on a web browser.

Create project
Get list of project

Get single owner
Create Owner

Database with data

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:

💖 💪 🙅 🚩
malomz
Demola Malomo

Posted on July 25, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related