DEV Community

Cover image for How to create a GraphQL server in Rust
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

How to create a GraphQL server in Rust

Written by Joshua Cooper✏️

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.

Project setup

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

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.

LogRocket Free Trial Banner

Setting up the web server

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

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

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

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);
        }
    });

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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

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.

Database schema

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

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.

GraphQL

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

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.

GraphQL mutations

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

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: "john@example.com", address: "19 Small Street London") {
    id,
    name,
    age,
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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.

GraphQL queries

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

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

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

Conclusion

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.


Monitor failed and slow GraphQL requests in production

While GraphQL has some features for debugging requests and responses, making sure GraphQL reliably serves resources to your production app is where things get tougher. If you're interested in ensuring network requests to the backend or third party are successful, try LogRocket.

Alt Text

LogRocket is like a DVR for web 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.


The post How to create a GraphQL server in Rust appeared first on LogRocket Blog.

Latest comments (1)

Collapse
 
gklijs profile image
Gerard Klijs

This is great. Already want to add a rust version of the GraphQL endpoint for kafka-graphql-examples. This will help me get started quickly. Thanks.