How to Build a GraphQL Server with Rust

Updated on August 21, 2023
How to Build a GraphQL Server with Rust header image

Introduction

Rust is a statically and strongly typed programming language that focuses on performance and safety. GraphQL is a query language for APIs that provides more flexibility to clients as compared to traditional REST APIs. This means, clients can request the data they need without making multiple requests to the API.

Below are benefits of using Rust for GraphQL APIs:

  • Performance: Rust is a compiled language that aims to offer high performance. It can match C and C++ language speed with some additional features like memory and concurrency safety
  • Memory management: Rust manages memory at compile time rather than relying on a garbage collection system
  • Type safety: Rust's type system ensures that the data in a GraphQL API is correct. It's able to catch type errors at compile time before they reach production
  • Concurrency: Rust allows building concurrent applications using multiple cores to handle multiple requests simultaneously

This article explains how to build a GraphQL API with Rust that stores data in a Vultr Managed PostgreSQL Database. You are to build a GraphQL server in Rust, then, push a Docker image to the Docker Hub Image Registry, and deploy the GraphQL server image to a Vultr Cloud Instance.

Prerequisites

Before you start:

On your local development machine:

  • Install the Rust toolchain, including cargo (Rust version >= 1.65)

      $ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

    When installed, activate it using the following command

      $ source "$HOME/.cargo/env"

    Verify the install Rust and Cargo versions

      $ rustc --version && cargo --version
  • Install Docker

Build a GraphQL Server in Rust

The GraphQL Server in this article is a Rust backend for an application called rustpaste built with async-graphql and axum libraries. The application allows users to store and share plain text online. Build the server and prepare it for production deployment as described in the steps below.

Create the Project

  1. Using cargo, create a new Rust project named rustpaste

     $ cargo new rustpaste

    The above command creates a new Cargo package, including a Cargo.toml and a src/main.rs file in the rustpaste directory.

  2. Switch to the new directory

     $ cd rustpaste
  3. Verify that the directory contains new files

     $ ls

    Output:

     Cargo.toml  src
  4. Back up the Cargo.tml file

     $ cp Cargo.toml Cargo.ORIG
  5. Using a text editor such as Nano, edit the Cargo.toml file

     $ nano Cargo.toml
  6. Update the existing content with the following configurations

     [package]
     name = "rustpaste"
     version = "0.1.0"
     edition = "2021"
    
     [dependencies]
     async-graphql = "5.0.10"
     async-graphql-axum = "5.0.10"
     axum = "0.6.18"
     dotenv = "0.15.0"
     nanoid = "0.4.0"
     serde = { version = "1.0.164", features = ["derive"] }
     sqlx = { version = "0.7.1", features = ["runtime-tokio-native-tls", "json", "postgres"] }
     thiserror = "1.0.44"
     tokio = { version = "1.28.2", features = ["full"] }

    Save and close the file

Set Up the Database

  1. Using cargo, install the sqlx-cli package

     $ cargo install sqlx-cli

    If the above command returns an error, verify that the OpenSSL package is available on your system. If not install it using the command below:

     $ sudo apt install libssl-dev
  2. Create a new .env file

     $ nano .env
  3. Add the following string to the file. Replace DATABASE_URL with your Vultr Managed Database for PostgreSQL connection string

     DATABASE_URL=postgresql://user:password@locahost:host/database

    Save and close the file

  4. Using sqlx-cli, create a new database

     $ sqlx database create
  5. Add the first migration to create a new SQL table

     $ sqlx migrate add add_paste_table
  6. Edit the newly added .sql file in the migrations folder

     $ nano migratons/.sql
  7. Add the following code to the file

     CREATE TABLE paste (
         id serial PRIMARY KEY,
         title text NOT NULL,
         content text NOT NULL,
         password text
     );

    Save and close the file

  8. Start migration to create a new paste database table using your SQL file and .env connection string

     $ sqlx migrate run

Create a new Rust Storage Layer

  1. Switch to the src directory

     $ cd src
  2. Create a new file named lib.rs

     $ nano src/lib.rs
  3. Add the following code to the file

     use async_graphql::futures_util::lock::Mutex;
     use std::sync::Arc;
     use thiserror::Error;
    
     mod storage;
     pub use storage::PasteStorage;
    
     #[derive(Clone)]
     pub struct Paste {
         id: String,
         title: String,
         content: String,
         password: Option<String>,
     }
    
     #[derive(Debug, Error)]
     pub enum PasteError {
         #[error("invalid id")]
         InvalidId,
         #[error("invalid password")]
         InvalidPassword,
         #[error("database error: {0}")]
         DatabaseError(#[from] sqlx::Error),
     }

    Save and close the file

    The above code creates a new struct named Paste and a new enum PasteError. The Struct Paste contains data of each paste, and the enum PasteError implements the trait Error from thiserror to simplify error handling.

  4. Create another file named storage.rs

     $ nano src/storage.rs
  5. Add the following code to the file

     use sqlx::{Pool, Postgres};
    
     use crate::{Paste, PasteError};
    
     pub struct PasteStorage {
         pool: Pool<Postgres>,
     }
    
     impl PasteStorage {
         pub fn new(pool: Pool<Postgres>) -> Self {
             PasteStorage { pool }
         }
     }
    
     impl PasteStorage {
         pub async fn insert(
             &mut self,
             id: String,
             title: String,
             content: String,
             password: Option<String>,
         ) -> Result<Paste, PasteError> {
             let paste = Paste {
                 id,
                 title,
                 content,
                 password,
             };
    
             sqlx::query!(
                 "INSERT INTO paste VALUES ($1, $2, $3, $4)",
                 paste.title,
                 paste.content,
                 paste.password
             )
             .execute(&self.pool)
             .await?;
             Ok(paste)
         }
    
         pub async fn update(
             &mut self,
             id: String,
             title: String,
             content: String,
             password: Option<String>,
         ) -> Result<Paste, PasteError> {
             let paste = Paste {
                 id,
                 title,
                 content,
                 password,
             };
    
             sqlx::query!(
                 "UPDATE paste set title=$2, content=$3, password=$4 where id=$1",
                 paste.id,
                 paste.title,
                 paste.content,
                 paste.password
             )
             .execute(&self.pool)
             .await?;
             Ok(paste)
         }
    
         pub async fn remove(&mut self, id: &str) -> Result<(), PasteError> {
             sqlx::query!("DELETE FROM paste WHERE id=$1", id)
                 .execute(&self.pool)
                 .await?;
             Ok(())
         }
    
         pub async fn get(&self, id: &str) -> Result<Option<Paste>, PasteError> {
             let result = sqlx::query!(
                 "SELECT id, title, content, password FROM paste WHERE id=$1",
                 id
             )
             .fetch_optional(&self.pool)
             .await?;
    
             match result {
                 Some(row) => Ok(Some(Paste {
                     id: row.id,
                     title: row.title,
                     content: row.content,
                     password: row.password,
                 })),
                 None => Ok(None),
             }
         }
    
         pub async fn get_all(&self) -> Result<Vec<Paste>, PasteError> {
             let results = sqlx::query!("SELECT id, title, content, password FROM paste")
                 .fetch_all(&self.pool)
                 .await?;
             let mut pastes = vec![];
             for row in results {
                 let p = Paste {
                     id: row.id,
                     title: row.title,
                     content: row.content,
                     password: row.password,
                 };
                 pastes.push(p);
             }
             Ok(pastes)
         }
     }

<<<<<<< HEAD Save and close the file

Save and close the file.

> 07acdedf1d73fde7de27c46263872b4c35f0eae8

The above code creates a new struct named `PasteStorage` that contains a pool to the PostgreSQL database. Later declarations contain methods to insert, update and query the data.

Create a Storage Layer

  1. Edit the main.rs file

     $ nano main.rs
  2. Add the following contents to the file

     use dotenv::dotenv;
     use nanoid::nanoid;
     use rustpaste::PasteStorage;
     use sqlx::PgPool;
     use std::env;
    
     async fn example(storage: &mut PasteStorage) {
         let id = nanoid!();
         let result = storage
             .insert(id, "new title".to_string(), "new content".to_string(), None)
             .await;
         match result {
             Err(e) => println!("{}", e),
             Ok(p) => println!("Inserted {:?}", p),
         }
         let results = storage.get_all().await;
         match results {
             Err(_) => println!("Something went wrong!"),
             Ok(pastes) => {
                 for p in pastes {
                     println!("{:?}", p);
                 }
             }
         }
     }
    
     #[tokio::main]
     async fn main() {
         dotenv().ok();
         let database_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file");
         let pool = PgPool::connect(&database_url).await.unwrap();
         let mut storage = PasteStorage::new(pool);
    
         example(&mut storage).await;
     }

<<<<<<< HEAD Save and close the file

Save and close the file.

> 07acdedf1d73fde7de27c46263872b4c35f0eae8

Below is what the `main` method does:

* Parses the `.env` file for the database connection and creates a pool object for the PostgreSQL database.
* Create a mutable variable `storage` using the `PasteStorage::new` method
* Calls the `example` method with a mutable reference to the `storage` variable
* The `example` method creates a paste with a random id using the `nanoid` crate, inserts the paste into the database, and queries all the existing pastes
  1. Run the program

     $ cargo run

    Your output should look like the one below:

     Finished dev [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/rustpaste`
     Inserted Paste { id: "68O__4C5uzhXS37ICW4df", title: "new title", content: "new content", password: None }
     Paste { id: "68O__4C5uzhXS37ICW4df", title: "new title", content: "new content", password: None }

Set Up the GraphQL Server

  1. Edit lib.rs file

     $ nano lib.rs
  2. Replace the existing code with the following contents

     use async_graphql::futures_util::lock::Mutex;
     use async_graphql::{Context, EmptySubscription, Object, Schema};
     use nanoid::nanoid;
     use std::str;
     use std::sync::Arc;
     use thiserror::Error;
    
     pub type ServiceSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
     pub struct QueryRoot;
     pub struct MutationRoot;
    
     mod storage;
     pub use storage::PasteStorage;
    
     pub type Storage = Arc<Mutex<PasteStorage>>;
    
     #[derive(Clone, Debug)]
     pub struct Paste {
         id: String,
         title: String,
         content: String,
         password: Option<String>,
     }
    
     #[derive(Debug, Error)]
     pub enum PasteError {
         #[error("invalid id")]
         InvalidId,
         #[error("invalid password")]
         InvalidPassword,
         #[error("database error: {0}")]
         DatabaseError(#[from] sqlx::Error),
     }
    
     #[Object]
     impl Paste {
         async fn id(&self) -> &str {
             &self.id
         }
         async fn title(&self) -> &str {
             &self.title
         }
         async fn content(&self) -> &str {
             &self.content
         }
     }
    
     #[Object]
     impl QueryRoot {
         pub async fn hello(&self) -> &'static str {
             "Hello RustPaste"
         }
    
         pub async fn all_pastes(&self, ctx: &Context<'_>) -> Result<Vec<Paste>, PasteError> {
             let storage = ctx.data_unchecked::<Storage>().lock().await;
             storage.get_all().await
         }
    
         pub async fn paste(&self, ctx: &Context<'_>, id: String) -> Result<Option<Paste>, PasteError> {
             let storage = ctx.data_unchecked::<Storage>().lock().await;
             storage.get(&id).await
         }
     }
    
     #[Object]
     impl MutationRoot {
         async fn create_paste(
             &self,
             ctx: &Context<'_>,
             title: String,
             content: String,
             password: Option<String>,
         ) -> Result<Paste, PasteError> {
             let mut storage = ctx.data_unchecked::<Storage>().lock().await;
             let id = nanoid!();
    
             storage.insert(id, title, content, password).await
         }
    
         async fn update_paste(
             &self,
             ctx: &Context<'_>,
             id: String,
             title: String,
             content: String,
             password: Option<String>,
         ) -> Result<Paste, PasteError> {
             let mut storage = ctx.data_unchecked::<Storage>().lock().await;
             let paste = storage.get(&id).await?;
    
             match paste {
                 None => Err(PasteError::InvalidId),
                 Some(paste) => match paste.password {
                     None => storage.update(id, title, content, paste.password).await,
                     Some(stored_pass) => match password {
                         None => Err(PasteError::InvalidPassword),
                         Some(input_pass) => {
                             if stored_pass == input_pass {
                                 return storage.update(id, title, content, Some(input_pass)).await;
                             }
                             Err(PasteError::InvalidPassword)
                         }
                     },
                 },
             }
         }
         async fn delete_paste(
             &self,
             ctx: &Context<'_>,
             id: String,
             password: Option<String>,
         ) -> Result<bool, PasteError> {
             let mut storage = ctx.data_unchecked::<Storage>().lock().await;
             let paste = storage.get(&id).await?;
    
             match paste {
                 None => Err(PasteError::InvalidId),
                 Some(paste) => match paste.password {
                     None => Ok(false),
                     Some(stored_pass) => match password {
                         None => Err(PasteError::InvalidPassword),
                         Some(input_pass) => {
                             if stored_pass == input_pass {
                                 storage.remove(&id).await?;
                                 return Ok(true);
                             }
                             Err(PasteError::InvalidPassword)
                         }
                     },
                 },
             }
         }
     }

    Save and close the file

    Below is what the above configuration does:

    • Creates a new struct QueryRoot and struct MutationRoot
    • Implements methods for querying, inserting, updating, and deleting pastes
    • Each method uses the Context from async_graphql to access the shared Storage
  3. Edit the main.rs file

     $ nano main.rs
  4. Replace the existing code with the following contents

     use async_graphql::futures_util::lock::Mutex;
     use async_graphql::http::GraphiQLSource;
     use async_graphql::{EmptySubscription, Schema};
     use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
     use axum::response::{self, IntoResponse};
     use axum::{routing::get, Extension, Router, Server};
     use dotenv::dotenv;
     use sqlx::PgPool;
     use std::env;
     use std::sync::Arc;
    
     use rustpaste::{MutationRoot, PasteStorage, QueryRoot, ServiceSchema};
    
     pub async fn graphql_handler(
         schema: Extension<ServiceSchema>,
         req: GraphQLRequest,
     ) -> GraphQLResponse {
         schema.execute(req.into_inner()).await.into()
     }
    
     pub async fn graphiql() -> impl IntoResponse {
         response::Html(GraphiQLSource::build().endpoint("/").finish())
     }
    
     #[tokio::main]
     async fn main() {
         dotenv().ok();
         let database_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file");
         let pool = PgPool::connect(&database_url).await.unwrap();
    
         let schema = Schema::build(QueryRoot, MutationRoot, EmptySubscription)
             .data(Arc::new(Mutex::new(PasteStorage::new(pool))))
             .finish();
    
         let app = Router::new()
             .route("/", get(graphiql).post(graphql_handler))
             .layer(Extension(schema));
    
         println!("Server is running at http://0.0.0.0:8080");
         Server::bind(&"0.0.0.0:8080".parse().unwrap())
             .serve(app.into_make_service())
             .await
             .unwrap();
     }

    Save and close the file

    The above code uses the async_graphql with axum to create a GraphQL server running on your localhost port 8080.

  5. Run the application

     $ cargo run
  6. Using a web browser such as Firefox, access the GraphQL IDE on port 8080 as below

     http://localhost:8080/
  7. Run the query below

     {
       hello
     }

    Your output should look like the one below:

     {
       "data": {
         "hello": "Hello RustPaste"
       }
     }
  8. Run another query

     {
       allPastes {
         id
         title
       }
     }

    Output:

     {
       "data": {
         "allPastes": [
           {
             "id": "68O__4C5uzhXS37ICW4df",
             "title": "new title"
           },
           {
             "id": "cH9T9zDs3pTdqc39_9JqO",
             "title": "new title"
           }
         ]
       }
     }

Access the GraphQL API using Curl

  1. In your terminal session, run the following command to query all pastes

     $ curl 'http://localhost:8080/' \
       -H 'content-type: application/json' \
       --data '{
       "query": "{ allPastes { id title} }"
       }'
  2. Query one paste

     $ curl 'http://localhost:8080/' \
       -H 'content-type: application/json' \
       --data '{
       "query": "{ paste (id: \"5o8efIH6NPUROuQa2DGu3\") { id title content} }"
       }'
  3. Create one paste

     $ curl 'http://localhost:8080/' \
       -H 'content-type: application/json' \
       --data '{
       "query": "mutation { createPaste (title: \"new title\", content: \"new content\") { id title content} }"
       }'
  4. Update a single paste

     $ curl 'http://localhost:8080/' \
       -H 'content-type: application/json' \
       --data '{
       "query": "mutation { updatePaste (id: \"wv6FqXCJitgxpJP53gd-X\", title: \"updated title\", content: \"updated content\") { id title content} }"
       }'
  5. Delete a single paste

     $ curl 'http://localhost:8080/' \
       -H 'content-type: application/json' \
       --data '{
       "query": "mutation { deletePaste (id: \"KUzUcOMss8J1s41J-riXp\") }"
       }'

Build and Push a Docker image to Docker Hub

  1. Switch to the project directory

     $ cd rustpaste
  2. Create a new Dockerfile

     $ nano DockerFile
  3. Add the following contents to the file

     FROM rust:bookworm as builder
     WORKDIR /usr/src/rustpaste
     COPY . .
     RUN cargo install --path .
    
     FROM debian:bookworm-slim
     RUN apt-get update && apt-get install -y libssl3 && rm -rf /var/lib/apt/lists/*
     COPY --from=builder /usr/local/cargo/bin/rustpaste /usr/local/bin/rustpaste
     CMD ["rustpaste"]

    Save and close the file.

    The above configuration uses rust:bookworm, which includes the Rust toolchain, to build the application. The runner stage uses the debian:bookworm-slim Debian 12 Docker image with a small size, installs the libssl3 package and copies the rustpaste binary from the builder stage.

  4. Build the Docker image

     $ docker build . -t rustpaste
  5. Verify that the Docker image is available on your server

     $ docker images

    Output:

     REPOSITORY      TAG       IMAGE ID       CREATED         SIZE
     rustpaste       latest    abe0709550ce   1 minutes ago   93.9M
  6. To test that your Docker image works, deploy it on your development machine as below.

     $ docker run --rm -p 8080:8080 -e DATABASE_URL='YOUR_DATABASE_URL' rustpaste

    Replace YOUR_DATABASE_URL with the DATABASE_URL value in your .env file

  7. Using curl, verify that the application listens on the host port 8080

     $ curl 127.0.0.1:8080
  8. Log in to Docker Hub

     $ docker login
  9. Tag the Docker Image with your Docker Hub ID. Replace example-user with your actual Docker ID

     $ docker tag rustpaste example-user/rustpaste:latest
  10. Push the Docker Image to Docker Hub. Replace example-user with your actual Docker Hub account

     $ docker push example-user/rustpaste:latest

Deploy the GraphQL API Application on your Production Server

To deploy your GraphQL API application on your Vultr production server, verify that docker actively runs on the server, create the necessary local directories, and deploy the application as described in the following steps

  1. Create a new rustpaste subdirectory in the /var directory

     $ mkdir /var/rustpaste
  2. Switch to the directory

     $ cd /var/rustpaste
  3. Create a new .env file

     $ nano .env
  4. Add the following configuration to the file. Replace postgresql://user:password@locahost:host/database with your actual Vultr Managed Database for PostgreSQL connection string

     DATABASE_URL=postgresql://user:password@locahost:host/database

<<<<<<< HEAD If you apply a new database different from your development database, copy the DATABASE_URL to your development machine and use sqlx-cli to run the database migration again

If you apply a new database different from your development database, copy the `DATABASE_URL` to your development machine and use `sqlx-cli` to run the database migration.

> 07acdedf1d73fde7de27c46263872b4c35f0eae8

    $ sqlx migrate run
  1. Deploy a Docker container with a restart always policy. Replace example-user with your actual Docker Hub account ID

     $ docker run -d --restart always --env-file .env -p 8080:8080 example-user/rustpaste:latest
  2. Allow the application port 8080 through the UFW firewall to accept incoming connections

     $ sudo ufw allow 8080/tcp
  3. In a new web browser window, load port 8080 on your public Server IP to access the application

     http://<YOUR_SERVER_IP>:8080

Conclusion

In this article, you managed to build a GraphQL server with Rust, created a Docker image and deployed the application to a production Vultr Cloud Server. By using GraphQL, you can have a fast, reliable, and high-performance backend server. To access the full application source code, visit the RustPaste repository.

Next Steps

To secure your GraphQL API and implement more solutions on your Vultr Production server, visit the following resources: