Create a GraphQL Server with Rust using Juniper

November 12, 2023
Written by
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Twilion

As far as modern applications are concerned, there are fewer things more important than an efficient communication medium between the client and server. Traditionally, RESTful APIs are the go-to choice for many developers, offering a structured approach to data exchange. However, GraphQL has challenged the status quo in recent years as it solves the problem of under-fetching or over-fetching - a common occurrence in RESTful communications.

Building a GraphQL server has been well-documented for several languages but not so much for Rust, so in this article, I will show you how to bring all that Rust-y goodness to the world of GraphQL by building one using Juniper. Juniper is a GraphQL server library for Rust that helps you build servers with minimal boilerplate and configuration. Additionally, by using Rust, performance and type safety are guaranteed.

What you will build

In this article, you will build the GraphQL server for a bird API. This API holds data for endangered species and has four key entities:

  1. Bird: This entity holds information on the bird such as the common name, scientific name etc
  2. Threat: This entity corresponds to a potential threat to a bird such as poaching
  3. Attribute: This entity corresponds to a bird’s attribute as identified by an attributor
  4. Attributor: This entity holds information on the attributor

To get familiar with writing queries your server will be able to handle the following queries:

  1. Get all birds
  2. Get a single bird

To get familiar with writing mutations, your server will be able to handle the following mutations:

  1. Add a new attribute
  2. Delete an existing attribute

Your API will save data to a MySQL database, with Diesel as an ORM. Because Juniper does not provide a web server, you will use Rocket to handle requests and provide the appropriate responses. The Juniper integration with Rocket also embeds GraphiQL for easy debugging.

Requirements

To follow this tutorial, you will need the following:

Additionally, Diesel recommends using the Diesel CLI for managing your database. It will be used in this tutorial to manage migrations. You can install it (with only the MySQL feature) using the following command:

cargo install diesel_cli --no-default-features --features mysql

If you encounter issues while running this command, check out the Diesel Getting Started guide, or try removing --no-default-features from the above command.

Get started

Where you create your Rust projects, create a new Rust project and change into it using the following commands.

bash
cargo new graphql_demo --bin
cd graphql_demo

Add project dependencies

In your editor or IDE of choice, update the dependencies section of the Cargo.toml file to match the following.

dependencies]
diesel = { version = "2.1.0"

Here’s what each crate does:

  • Diesel: Diesel is the ORM that will be used to interact with the database. The MySQL feature is specified to provide the requisite API for interacting with a MySQL-based database. The r2d2 feature will be used to set up a connection pool for the database.
  • Dotenvy : Dotenvy helps with loading environment variables. It is a well-maintained version of the dotenv crate.
  • Juniper: Juniper is a GraphQL server library for Rust
  • Juniper_rocket: Juniper_rocket is an integration that allows you to build GraphQL servers with Juniper, and serve them with Rocket.
  • Rocket: Rocket will be used for handling incoming requests and returning appropriate responses.

Lock project dependencies

Between the review and publishing stage of this tutorial, some third party crates have released updates which would break your application.

As a short term solution (pending updates to the Juniper crate), you can download this Cargo.lock file to the project’s top level folder. This will ensure that all the crate versions are compatible and that your application will run as expected.

Set the required environment variable(s)

Next, create a new file called .env in the project's top-level folder. Then, in the configuration entry below, replace the placeholder values with your database credentials and paste it into .env.

DATABASE_URL=mysql://<<DB_USERNAME>>:<<DB_PASSWORD>>@<<DB_HOST_OR_IP>>:<<DB_PORT>>/bird_db

Set up database

Next, create your database using the following command.

diesel setup

After that, create a migration for your database, by running the following command. This migration will create the database tables, and seed them:

diesel migration generate create_tables

You will see a response similar to the one below:

Creating migrations/2023-09-13-090548_create_tables/up.sql
Creating migrations/2023-09-13-090548_create_tables/down.sql

For each migration, the up.sql file contains the SQL for changing the database. The commands to revert the changes in up.sql will be stored in the down.sql file.

Replace the contents of the newly created up.sql and down.sql migration files to match the respective files in this Gist.

Next, apply the changes in the migrations using the following command:

diesel migration run

When the migration has run, you can check that the database has been updated successfully using the following SQL command with your MySQL client of choice.

SELECT TABLE_NAME, TABLE_ROWS 
FROM INFORMATION_SCHEMA.TABLES 
WHERE TABLE_SCHEMA = 'bird_db';

The resulting table should match the one shown below:

TABLE_NAMETABLE_ROWS
__diesel_schema_migrations1
attribute25
attributor22
bird16
bird_threat85
threat14

In addition to setting up the database, the Diesel CLI created a new file named schema.rs in the src folder. This file contains macros based on your table structure, making it easier for you to interact with the database. Have a read about Diesel’s schema if you'd like to know more.

Next, add a module for the database. In the src folder, create a new file named database.rs and add the following code to it.

use diesel::r2d2::{ConnectionManager, Pool, PoolError};
use diesel::MysqlConnection;
use dotenvy::dotenv;
use std::env;

pub type MysqlPool = Pool<ConnectionManager<MysqlConnection>>;

fn init_pool(database_url: &str) -> Result<MysqlPool, PoolError> {
    let manager = ConnectionManager::<MysqlConnection>::new(database_url);
    Pool::builder().build(manager)
}

fn establish_connection() -> MysqlPool {
    dotenv().ok();

    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    init_pool(&database_url).unwrap_or_else(|_| panic!("Could not create database pool"))
}

pub struct Database {
    pub pool: MysqlPool,
}

impl Database {
    pub fn new() -> Database {
        Database {
            pool: establish_connection(),
        }
    }
}

The first declaration in this module is a type declaration named MySqlPool, for the database pool. Next, a function named init_pool() is declared. This function takes a string corresponding to the database URL and returns a Result enum. If the pool was successfully built, the Ok variant of the result will be the earlier declared type (MySqlPool).

The next function (establish_connection()) retrieves the DATABASE_URL environment variable and passes it to the init_pool. The result is unwrapped and returned to the function caller. In the event that an error is encountered, the application will panic and shut down.

Next, a Database struct is declared. This struct has only one field named pool of type  MySqlPool. Finally, a function named new() is implemented for the Database struct. This function calls the establish_connection() function to create a new database pool.

Declare models

In addition to the AttributeAttributor, Bird, and Threat models mentioned in the What you will build section, your application will include the following models:

  1. BirdThreat: This model links a bird to an associated threat.
  2. BirdResponse: This model corresponds to the GraphQL response when a query is made for a single bird.
  3. AttributeInput: This model corresponds to the expected type of the mutation argument to add a new Attribute for a bird.
  4. AttributeResponse: This model corresponds to the GraphQL response when a mutation for adding a new attribute is received.

In the src folder, create a new file named model.rs and add the following code to it:

use diesel::prelude::*;
use juniper::{GraphQLInputObject, GraphQLObject};

use crate::schema::*;

#[derive(GraphQLObject, Queryable, Insertable, Selectable, Identifiable, Associations)]
#[diesel(belongs_to(Bird))]
#[diesel(belongs_to(Attributor))]
#[diesel(table_name = attribute)]
pub struct Attribute {
    pub id: i32,
    pub bird_id: i32,
    pub attributor_id: i32,
    pub bio: String,
    pub link: String,
}

#[derive(GraphQLObject, Queryable, Identifiable)]
#[diesel(table_name = attributor)]
pub struct Attributor {
    pub id: i32,
    pub name: String,
    pub bio: String,
}

#[derive(GraphQLObject, Queryable, Identifiable, Selectable)]
#[diesel(table_name = bird)]
pub struct Bird {
    pub id: i32,
    pub common_name: String,
    pub commonwealth_status: String,
    pub nsw_status: String,
    pub profile: String,
    pub scientific_name: String,
    pub stats: String,
    pub stats_for: String,
}

#[derive(GraphQLObject, Queryable, Identifiable, Selectable)]
#[diesel(table_name = threat)]
pub struct Threat {
    pub id: i32,
    pub name: String,
}

#[derive(GraphQLObject, Queryable, Selectable, Identifiable, Associations)]
#[diesel(belongs_to(Bird))]
#[diesel(belongs_to(Threat))]
#[diesel(table_name = bird_threat)]
#[diesel(primary_key(bird_id, threat_id))]
pub struct BirdThreat {
    pub bird_id: i32,
    pub threat_id: i32,
}

#[derive(GraphQLObject)]
pub struct BirdResponse {
    pub bird: Bird,
    pub threats: Vec<Threat>,
    pub attributes: Vec<Attribute>,
}

#[derive(GraphQLInputObject, Insertable)]
#[diesel(table_name = attribute)]
pub struct AttributeInput {
    pub bird_id: i32,
    pub attributor_id: i32,
    pub bio: String,
    pub link: String,
}

#[derive(GraphQLObject)]
pub struct AttributeResponse {
    pub bird: Bird,
    pub attributor: Attributor,
    pub bio: String,
    pub link: String,
}

Structs (or Enums) with the GraphQLObject attribute, are exposed to GraphQL — allowing you to query for specific fields. In the same vein, the GraphQLInputObject attribute exposes structs as input objects. The Associations, Identifiable, Insertable, Queryable, and Selectable attributes are provided by Diesel for a simplified means of interacting with the database.

Implement GraphQL functionality

In the src folder, create a new file named resolver.rs and add the following code to it.

use diesel::prelude::*;
use juniper::{EmptySubscription, FieldResult, graphql_object, RootNode};
use crate::{database::Database, model::*, schema::*};

impl juniper::Context for Database {}

pub type Schema = RootNode<'static, Query, Mutation, EmptySubscription<Database>>;

pub struct Query;

#[graphql_object(context = Database)]
impl Query {
    fn birds(#[graphql(context)] database: &mut Database) -> FieldResult<Vec<Bird>> {
        use crate::schema::bird::dsl::*;
        use diesel::RunQueryDsl;
        let connection = &mut database.pool.get()?;
        let bird_response = bird.load::<Bird>(connection)?;
        Ok(bird_response)
    }

    fn bird(
        #[graphql(context)] database: &mut Database,
        #[graphql(description = "id of the bird")] search_id: i32,
    ) -> FieldResult<BirdResponse> {
        let connection = &mut database.pool.get()?;
        let bird_response = bird::table.find(&search_id).first(connection)?;
        let bird_threats = bird_threat::table
            .filter(bird_threat::bird_id.eq(&search_id))
            .select(bird_threat::threat_id)
            .load::<i32>(connection)?;
        let threats_response = threat::table
            .filter(threat::id.eq_any(&bird_threats))
            .load::<Threat>(connection)?;
        let bird_attributes = attribute::table
            .filter(attribute::bird_id.eq(&search_id))
            .load::<Attribute>(connection)?;

        Ok(BirdResponse {
            bird: bird_response,
            threats: threats_response,
            attributes: bird_attributes,
        })
    }
}

pub struct Mutation;

#[graphql_object(context = Database)]
impl Mutation {
    fn new_attribute(
        #[graphql(context)] database: &mut Database,
        attribute_input: AttributeInput,
    ) -> FieldResult<AttributeResponse> {
        let connection = &mut database.pool.get()?;

        diesel::insert_into(attribute::table)
            .values(&attribute_input)
            .execute(connection)?;

        let bird_response = bird::table
            .find(&attribute_input.bird_id)
            .first(connection)?;

        let attributor_response = attributor::table
            .find(&attribute_input.attributor_id)
            .first(connection)?;

        Ok(AttributeResponse {
            bird: bird_response,
            attributor: attributor_response,
            bio: attribute_input.bio,
            link: attribute_input.link,
        })
    }

    fn remove_attribute(
        #[graphql(context)] database: &mut Database,
        attribute_id: i32,
    ) -> FieldResult<String> {
        let connection = &mut database.pool.get()?;
        diesel::delete(attribute::table.filter(attribute::id.eq(attribute_id)))
            .execute(connection)?;
        Ok("Attribute deleted successfully".to_string())
    }
}

The first step is to make the Database struct usable by Juniper. This is done by making it implement the Context marker trait.

Next, a Schema type is declared. This combines the Query and Mutation (defined afterwards). The application does not support subscriptions, so the EmptySubscription struct provided by Juniper will be used instead.

Next, the Query struct is declared. It also has the graphql_object attribute which gives it access to the application’s shared state (the database in this case). This makes the database available to the resolver functions declared within the Query struct.

In the same manner, the Mutation struct is declared, marked with the graphql_object attribute, and the corresponding mutation functions declared as struct implementations.

Putting it all together

You’ve built the database, declared your models, and implemented your GraphQL functionality. All that’s left is for you to add are some endpoints to expose your GraphQL server via Rocket. To do this, open the main.rs file in the src folder and update the code in it to match the following.

use database::Database;
use resolver::{Query, Schema, Mutation};
use juniper::EmptySubscription;
use rocket::{response::content, State};

mod database;
mod model;
mod schema;
mod resolver;

#[rocket::get("/")]
fn graphiql() -> content::RawHtml<String> {
    juniper_rocket::graphiql_source("/graphql", None)
}

#[rocket::get("/graphql?<request>")]
fn get_graphql_handler(
    context: &State<Database>,
    request: juniper_rocket::GraphQLRequest,
    schema: &State<Schema>,
) -> juniper_rocket::GraphQLResponse {
    request.execute_sync(schema, context)
}

#[rocket::post("/graphql", data = "<request>")]
fn post_graphql_handler(
    context: &State<Database>,
    request: juniper_rocket::GraphQLRequest,
    schema: &State<Schema>,
) -> juniper_rocket::GraphQLResponse {
    request.execute_sync(schema, context)
}

#[rocket::main]
async fn main() {
    let _ = rocket::build()
        .manage(Database::new())
        .manage(Schema::new(
            Query,
            Mutation,
            EmptySubscription::<Database>::new(),
        ))
        .mount(
            "/",
            rocket::routes![graphiql, get_graphql_handler, post_graphql_handler],
        )
        .launch()
        .await
        .expect("server to launch");
}

The graphiql() function is the entry point to the application and serves the GraphQL playground as a response, while the get_graphql_handler() and post_graphql_handler() functions are used to handle GraphQL requests and return the appropriate response.

In the main() function, a new Rocket instance is created using the build() function. Then a Database instance, and Schema instance are passed to the manage() function, which enables Rocket’s state management for both resources. Finally the instance is launched via the launch() function.

Running the application

If you haven't already, download this Cargo.lock file to the project’s top level folder to avoid issues with some third party crates. 

Then, run the application using the following command.

cargo run

By default, the application will be served on port 8000. Open https://localhost:8000 in your browser.

The GraphiQL interface rendered in the browser. There are two panes within the interface. The left allows for queries to be entered and run. The right will show the results of the query.

Get all birds

Paste the following code to get all birds.

query GetAllBirds{
  birds{
    id,
    commonName,
    scientificName,
    commonwealthStatus,
    profile
  }
}

For each bird, you will receive the id, commonName, scientificName, commonwealthStatus, and profile as shown below.

The GraphiQL interface with a query to get all birds in the left-hand side query pane and the response to that query in the results pane on the right hand side.

Get a single bird

Use the following query to retrieve the details for a single bird.

query GetBird{
  bird(searchId: 3){
        bird{
      nswStatus
    }
    threats{
      name
    }
    attributes{
      link,
      bio,
    }
  }
}

For the returned bird, you will receive the commonwealth status. In addition you will see the associated threats (only by name), and the bird attributes (link and bio) as shown below.

The GraphiQL interface with a query to find a bird by id in the left-hand side query pane and the response to that query in the results pane on the right hand side.

Add new attribute

Use the following to send a mutation which adds a new attribute for the specified bird.

mutation AddNewAttribute($attribute: AttributeInput!) {
  newAttribute(attributeInput: $attribute) {
    bird {
      commonName
    }
    attributor {
      name
    }
    link
  }
}

For the $attribute variable, add a query variable as follows:

{
  "attribute": {
    "birdId": 1, 
    "attributorId": 3, 
    "link": "https://localhost:8000", 
    "bio": "https://www.blogger.com/profile/05959326240924026673"
  }
}

The GraphiQL interface with a query to add a new Mutation attribute in the left-hand side query pane, and query variables in a panel underneath that, along with the response to the query in the results pane on the right hand side.

Delete an attribute

Use the following command to send a mutation which deletes an attribute from the database.

mutation deleteAttribute{
  removeAttribute(attributeId: 28)
}

The GraphiQL interface with a mutation to delete an attribute in the left-hand side query pane and the response to that query in the results pane on the right hand side.

There you have it!

Well done! I bet building the GraphQL server was easier than you expected. With five small modules, you were able to set up a shiny new GraphQL server. Not only that, you made it performant by setting up a connection pool for your MySQL database. Pretty neat right?

There’s still some other things to try out, such as adding more queries and mutations to expand the application’s functionality.

The entire codebase is available on GitHub should you get stuck at any point. I’m excited to see what else you come up with. Until next time, make peace not war ✌🏾

Joseph Udonsak is a software engineer with a passion for solving challenges – be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends. Find him at LinkedIn, Medium, and Dev.to.