How to Build a GraphQL Server with Rust
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:
- Deploy a OneClick Docker Server using the Vultr marketplace application
- Deploy a Vultr Managed Database for PostgreSQL
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
Using
cargo
, create a new Rust project namedrustpaste
$ cargo new rustpaste
The above command creates a new
Cargo
package, including aCargo.toml
and asrc/main.rs
file in therustpaste
directory.Switch to the new directory
$ cd rustpaste
Verify that the directory contains new files
$ ls
Output:
Cargo.toml src
Back up the
Cargo.tml
file$ cp Cargo.toml Cargo.ORIG
Using a text editor such as
Nano
, edit theCargo.toml
file$ nano Cargo.toml
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
Using
cargo
, install thesqlx-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
Create a new
.env
file$ nano .env
Add the following string to the file. Replace
DATABASE_URL
with your Vultr Managed Database for PostgreSQL connection stringDATABASE_URL=postgresql://user:password@locahost:host/database
Save and close the file
Using
sqlx-cli
, create a new database$ sqlx database create
Add the first migration to create a new SQL table
$ sqlx migrate add add_paste_table
Edit the newly added
.sql
file in themigrations
folder$ nano migratons/.sql
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
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
Switch to the
src
directory$ cd src
Create a new file named
lib.rs
$ nano src/lib.rs
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 enumPasteError
. The StructPaste
contains data of each paste, and the enumPasteError
implements the traitError
fromthiserror
to simplify error handling.Create another file named
storage.rs
$ nano src/storage.rs
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
Edit the
main.rs
file$ nano main.rs
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
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
Edit
lib.rs
file$ nano lib.rs
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 structMutationRoot
- Implements methods for querying, inserting, updating, and deleting pastes
- Each method uses the
Context
fromasync_graphql
to access the sharedStorage
- Creates a new struct
Edit the
main.rs
file$ nano main.rs
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
withaxum
to create a GraphQL server running on your localhost port8080
.Run the application
$ cargo run
Using a web browser such as Firefox, access the
GraphQL IDE
on port8080
as belowhttp://localhost:8080/
Run the query below
{ hello }
Your output should look like the one below:
{ "data": { "hello": "Hello RustPaste" } }
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
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} }" }'
Query one paste
$ curl 'http://localhost:8080/' \ -H 'content-type: application/json' \ --data '{ "query": "{ paste (id: \"5o8efIH6NPUROuQa2DGu3\") { id title content} }" }'
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} }" }'
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} }" }'
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
Switch to the project directory
$ cd rustpaste
Create a new
Dockerfile
$ nano DockerFile
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 thedebian:bookworm-slim
Debian 12 Docker image with a small size, installs thelibssl3
package and copies therustpaste
binary from the builder stage.Build the Docker image
$ docker build . -t rustpaste
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
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 theDATABASE_URL
value in your.env
fileUsing
curl
, verify that the application listens on the host port8080
$ curl 127.0.0.1:8080
Log in to Docker Hub
$ docker login
Tag the Docker Image with your Docker Hub ID. Replace
example-user
with your actual Docker ID$ docker tag rustpaste example-user/rustpaste:latest
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
Create a non-root sudo user
Switch to the sudo user account
# su example-user
Create a new
rustpaste
subdirectory in the/var
directory$ mkdir /var/rustpaste
Switch to the directory
$ cd /var/rustpaste
Create a new
.env
file$ nano .env
Add the following configuration to the file. Replace
postgresql://user:password@locahost:host/database
with your actual Vultr Managed Database for PostgreSQL connection stringDATABASE_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
Deploy a Docker container with a restart
always
policy. Replaceexample-user
with your actual Docker Hub account ID$ docker run -d --restart always --env-file .env -p 8080:8080 example-user/rustpaste:latest
Allow the application port
8080
through the UFW firewall to accept incoming connections$ sudo ufw allow 8080/tcp
In a new web browser window, load port
8080
on your public Server IP to access the applicationhttp://<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: