As the ecosystem around Rust grows, it’s becoming a more appealing option for backend web services. In this guide, we’ll show you how to get started with GraphQL in Rust using the Juniper library for queries and mutations that persist to Postgres.
For reference, the full source code for the final application is available on GitHub.
Start by creating a new project with Cargo and adding the needed dependencies to Cargo.toml
.
cargo new graphql_intro cd graphql_intro # Cargo.toml [dependencies] warp = "0.2" tokio = { version = "0.2", features = ["macros"] } serde_json = "1.0" futures = { version = "0.3.1", features = ["compat"] } futures-macro = "=0.3.1" juniper = { git = "https://github.com/graphql-rust/juniper", branch = "async-await", features = ["async"] } tokio-postgres = { version = "0.5", features = ["with-uuid-0_8"] } uuid = { version = "0.8", features = ["v4"] }
We’ll use Juniper for the GraphQL-specific functionality, warp for the web server, and tokio-postgres
to access a database. Since we’ll be using async Rust, an executor is needed to poll Futures. In this example, we’ll use the executor provided by Tokio with the macros feature flag for an async main function.
Before we get started with GraphQL, we should set up a web server to handle the HTTP requests. One of the great things about Juniper is that it works with any web server with very little effort. If you want to use something other than warp, feel free adapt this example accordingly.
Only two routes are needed in the web server: one to handle GraphQL requests and one to serve the GraphiQL test client.
// main.rs use warp::Filter; use std::sync::Arc; use juniper::http::graphiql::graphiql_source; #[tokio::main] async fn main () { let schema = Arc::new(Schema::new(QueryRoot, MutationRoot)); // Create a warp filter for the schema let schema = warp::any().map(move || Arc::clone(&schema)); let ctx = Arc::new(Context { client }); // Create a warp filter for the context let ctx = warp::any().map(move || Arc::clone(&ctx)); let graphql_route = warp::post() .and(warp::path!("graphql")) .and(schema.clone()) .and(ctx.clone()) .and(warp::body::json()) .and_then(graphql); let graphiql_route = warp::get() .and(warp::path!("graphiql")) .map(|| warp::reply::html(graphiql_source("graphql"))); let routes = graphql_route.or(graphiql_route); warp::serve(routes).run(([127, 0, 0, 1], 8000)).await; }
This won’t compile yet; we still need to define the Schema
, QueryRoot
, MutationRoot
, and Context
types.
First, we defined some schema and made it into a warp filter so we can access it in our route handlers. We then did the same thing for a context, which can contain things like database connections.
Next, we created the graphql_route
variable, which is a warp filter that will match any POST request to the path “graphql,” then make the schema, context and JSON body available to a handler called graphql
, which we’ll define later.
Similarly, the graphiql_route
variable is a filter that will match any GET request to the path “graphiql” and respond with the HTML for a GraphiQL client.
Finally, those filters are combined and the server is started.
To get this code to compile, let’s define those types that we missed.
use juniper::RootNode; use tokio_postgres::Client; struct QueryRoot; struct MutationRoot; #[juniper::graphql_object(Context = Context)] impl QueryRoot {} #[juniper::graphql_object(Context = Context)] impl MutationRoot {} type Schema = RootNode<'static, QueryRoot, MutationRoot>; struct Context { client: Client, } impl juniper::Context for Context {}
Next, define the graphql
route handler.
use std::convert::Infallible; use juniper::http::GraphQLRequest; async fn graphql( schema: Arc<Schema>, ctx: Arc<Context>, req: GraphQLRequest, ) -> Result<impl warp::Reply, Infallible> { let res = req.execute_async(&schema, &ctx).await; let json = serde_json::to_string(&res).expect("Invalid JSON response"); Ok(json) }
Create a connection to a Postgres database.
use tokio_postgres::NoTls; #[tokio::main] async fn main() { let (client, connection) = tokio_postgres::connect("host=localhost user=postgres", NoTls) .await .unwrap(); // The connection object performs the actual communication with the database, // so spawn it off to run on its own. tokio::spawn(async move { if let Err(e) = connection.await { eprintln!("connection error: {}", e); } }); // ... }
Note that the client we created here is what’s used in ctx
. From now on, you’ll need to have a running Postgres instance to connect to it. If you want to use Docker, the following command will spin up a database container for you.
docker run --rm -it -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:alpine
If you’re using different credentials for your database, be sure to put them in tokio_postgres::connect()
. At this point, you can compile and run the server, then open http://localhost:8000/graphiql in your browser to see if it’s working.
The last thing to do before moving on to GraphQL resolvers is to define a database schema. For this example, we’ll make a customer API that allows us to query customer data and create new customer entries.
Let’s create a table after the connection is established.
client .execute( "CREATE TABLE IF NOT EXISTS customers( id UUID PRIMARY KEY, name TEXT NOT NULL, age INT NOT NULL, email TEXT UNIQUE NOT NULL, address TEXT NOT NULL )", &[], ) .await .expect("Could not create table");
The first argument in the execute method is just normal SQL and the second is an array of paramaters. In this case, we aren’t using any paramaters, but we will use them later.
Now that we’ve set up the server boilerplate and database, we can start implementing GraphQL resolvers to make it function.
Let’s create a struct for customer data.
#[derive(juniper::GraphQLObject)] struct Customer { id: String, name: String, age: i32, email: String, address: String, }
Notice the derive macro above the struct. This is all we need to do to make our custom data type work seamlessly with Juniper. Since our database is empty at the moment, we’ll start with GraphQL mutations to add customer data.
Remember MutationRoot
from earlier? It’s the struct where all GraphQL mutations are implemented.
The simplest mutation we can make is to create a record for a new customer. Here’s a dummy register_customer
method inside the impl
block we created before.
#[juniper::graphql_object(Context = Context)] impl MutationRoot { async fn register_customer( ctx: &Context, name: String, age: i32, email: String, address: String, ) -> juniper::FieldResult<Customer> { Ok(Customer { id: "1".into(), name, age, email, address, }) } }
Again, we make use of Rust macros to remove lots of boilerplate. In this case, we’re making the context, which contains our database connection, available to all mutations.
For now the register_customer
method immediately returns a user with an ID of 1
and the name, age, email, and address that are passed in. The user is wrapped in Ok()
because this method returns a Result
.
Now you can run the server and open a browser at http://localhost:8000/graphiql to test this dummy mutation.
mutation { registerCustomer(name: "John Smith", age: 29, email: "[email protected]", address: "19 Small Street London") { id, name, age, } }
As you can see, our idiomatic Rust snake_cased method name has been changed to idiomatic GraphQL camelCase automatically and all the GraphQL types have been derived from our Customer
struct. If you try to remove some of the arguments to registerCustomer
, you’ll get a nice error message. Rust doesn’t have a null type, so we would have to explicitly opt into having a nullable field with Rust’s Option
type.
Instead of just returning the data, we can store it in our database.
async fn register_customer( ctx: &Context, name: String, age: i32, email: String, address: String, ) -> juniper::FieldResult<Customer> { let id = uuid::Uuid::new_v4(); let email = email.to_lowercase(); ctx.client .execute( "INSERT INTO customers (id, name, age, email, address) VALUES ($1, $2, $3, $4, $5)", &[&id, &name, &age, &email, &address], ) .await?; Ok(Customer { id: id.to_string(), name, age, email, address, }) }
Now we’re generating a random UUID then save the user in the database. We’re also normalizing the provided email by converting it to lowercase. You can see how paramaterized SQL looks with tokio_postgres
here, where the second argument to execute
is a list of references to the data we want to use.
Unlike the dummy example, this can produce an error. Fortunately, Juniper will format the response accordingly. Anything that implements std::fmt::Display
can be formatted as an error, so it’s easy to use custom error messages. But for now, we’ll just use the ones provided by tokio_postgres
.
Other mutations will be similar to this one. Let’s examine how we’d update a customer’s email and delete a customer record.
async fn update_customer_email( ctx: &Context, id: String, email: String, ) -> juniper::FieldResult<String> { let uuid = uuid::Uuid::parse_str(&id)?; let email = email.to_lowercase(); let n = ctx .client .execute( "UPDATE customers SET email = $1 WHERE id = $2", &[&email, &uuid], ) .await?; if n == 0 { return Err("User does not exist".into()); } Ok(email) } async fn delete_customer(ctx: &Context, id: String) -> juniper::FieldResult<bool> { let uuid = uuid::Uuid::parse_str(&id)?; let n = ctx .client .execute("DELETE FROM customers WHERE id = $1", &[&uuid]) .await?; if n == 0 { return Err("User does not exist".into()); } Ok(true) }
This time, we’re checking to see how many rows are updated. If no rows are updated, it means the user didn’t exist and we return an error.
Queries are implemented in a similar way to mutations, but using QueryRoot
instead of MutationRoot
. Our first query will simply find a customer with a given ID.
#[juniper::graphql_object(Context = Context)] impl QueryRoot { async fn customer(ctx: &Context, id: String) -> juniper::FieldResult<Customer> { let uuid = uuid::Uuid::parse_str(&id)?; let row = ctx .client .query_one( "SELECT name, age, email, address FROM customers WHERE id = $1", &[&uuid], ) .await?; let customer = Customer { id, name: row.try_get(0)?, age: row.try_get(1)?, email: row.try_get(2)?, address: row.try_get(3)?, }; Ok(customer) } }
Instead of using execute
on the database client, we use query_one
, which will return exactly one database row or an error. We’d do a similar thing to query a list of customers.
async fn customers(ctx: &Context) -> juniper::FieldResult<Vec<Customer>> { let rows = ctx .client .query("SELECT id, name, age, email, address FROM customers", &[]) .await?; let mut customers = Vec::new(); for row in rows { let id: uuid::Uuid = row.try_get(0)?; let customer = Customer { id: id.to_string(), name: row.try_get(1)?, age: row.try_get(2)?, email: row.try_get(3)?, address: row.try_get(4)?, }; customers.push(customer); } Ok(customers) }
This time, we’re using query
because we expect more than one row from the database. After that, loop through each row and add each customer to a vector. Try this query in the GraphiQL test client; it should return a list of customers.
{ customers { id name email address } }
Now you should have a good grasp on how GraphQL in Rust works with Juniper. Here we just used a single data type, but adding more works in exactly the same way. You can even use fields with nested custom data types and, due to the macros provided by Juniper, everything will just work. The next step could be to add authentication and permissions to the API and use a custom error type for all possible failure conditions.
Rust is a great option for building reliable and performant web backends in general, and its powerful macro support makes working with GraphQL an absolute pleasure.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic GraphQL requests to quickly understand the root cause. In addition, you can track Apollo client state and inspect GraphQL queries' key-value pairs.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCompare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.