Implementing a Relay GraphQL Server in Rust Part 1

Jordan Webster
7 min readDec 2, 2023

--

Almost all GraphQL server libraries leave users on their own when it comes to ensuring that their GraphQL API follows the Relay GraphQL Server Specification. Notable exceptions I’m aware of are HotChocolate (C#) and ent (Go) that offer Relay support out of the box. Exceptions, however, often make many assumptions that may not work for you. Ent for example is a whole ORM and more.

I’m a big fan of ‘implementation first’ GraphQL libraries which get out of your way and place very few, if any, constraints on the design of your solution. At the other extreme are very opinionated all-in GraphQL libraries which if they don’t support Relay out of the box, or you have slightly different requirements, you’re probably going to have a rough time shoehorning Relay into it.

This is the first of a three part series to implement Relay on a popular implementation first Rust library called Juniper. Hopefully you’ll see how if a particular solution doesn’t work for you, you can build on top of a lighter weight solution to satisfy your needs. The three parts are broken down as follows:

  1. Implementing the Node interface;
  2. Implementing the Connection interface;
  3. Solving the N+1 problem with dataloaders (not Relay specific, but highly desirable).

What Makes a GraphQL Server Relay Compliant?

There are two main things we are concerned about:

  1. The ability to refetch data, and;
  2. The ability to page through edges.

For the former we leverage the Node interface and for the latter we leverage the Connection interface.

The Node Interface

interface Node {
id: ID!
}

The Node interface is straight forward. Any instance of a type that implements the Node interface must have a globally (across all Nodes) unique ID. Furthermore, Relay requires that we can refetch this object with the node field on Query:

type Query {
...
node(id: ID!): Node
...
}

The Star Wars API

Let’s build a Relay compliant GraphQL API to wrap the Star Wars API. Luckily for us, there is some prior art in this space on the GraphQL GitHub. We can leverage their scripts/download.js script to download all of the Star Wars data to make our life a little bit easier rather than having to issue HTTP requests.

Adding Our First Types

If you’re following along, this assumes that you’ve already got a basic Juniper project up and running. If not, I recommend you read through their quickstart and then you should be able to set up a very barebones server like this.

We’ll start by adding just two of the types and a small sample of their fields:

use juniper::GraphQLObject;
use juniper::ID;

#[derive(GraphQLObject)]
struct Film {
id: ID,
title: String,
}

#[derive(GraphQLObject)]
struct Person {
id: ID,
name: String,
}

Next, we’ll read the data we downloaded from the Star Wars API into our Juniper context like so:

let file = File::open("src/data.json").expect("data.json must be present");
let reader = BufReader::new(file);
let data: Arc<serde_json::Map<String, serde_json::Value>> =
Arc::new(serde_json::from_reader(reader).expect("data.json must be valid JSON"));
let data = warp::any().map(move || data.clone());
let context_extractor = warp::any()
.and(data)
.map(|data: Arc<serde_json::Map<String, serde_json::Value>>| Context { data })
.boxed();

We’ll deserialize the JSON into proper structs a little bit later on but let’s just add the basic film and person fields to our Query:

#[graphql_object(context = Context)]
impl Query {
fn film(context: &Context, id: ID) -> Option<Film> {
context.data.get(&id.to_string()).map(|value| Film {
id,
title: value.get("title").unwrap().as_str().unwrap().to_string(),
})
}

fn person(context: &Context, id: ID) -> Option<Person> {
context.data.get(&id.to_string()).map(|value| Person {
id,
name: value.get("name").unwrap().as_str().unwrap().to_string(),
})
}
}

It’s not going to ship to production but at least we have something to query and play around with. Altogether, it should now look something a little like this.

Fetching Nodes

Next we need to add the Node interface. Juniper requires that GraphQL interfaces be implemented via a trait, which makes sense however I wish we could use an enum directly too. In any case, let’s add our Node interface:

#[graphql_interface(for = [Film, Person])]
trait Node {
fn id(&self) -> &ID;
}

Note that we have to specify which types implement this interface. Namely I’ve added the Film and Person types.

Juniper handles GraphQL interfaces for us by using a macro to generate an enum under the hood. In this case it generates an enum called NodeValue that looks like this:

enum NodeValue {
Person(Person),
Film(Film),
}

Now we need to declare that each of these types implements the interface:

#[derive(GraphQLObject)]
// Notice the name of the generated enum here
#[graphql(impl = NodeValue)]
struct Film {
id: ID,
title: String,
}

#[derive(GraphQLObject)]
// The same generated enum is also used for this type
#[graphql(impl = NodeValue)]
struct Person {
id: ID,
name: String,
}

And go ahead and implement the Nodetrait for these types:

impl Node for Person {
fn id(&self) -> &ID {
&self.id
}
}

impl Node for Film {
fn id(&self) -> &ID {
&self.id
}
}

Note carefully that here is where we can also ensure that each node has a globally unique ID. If, for example, a Person lives in a People table and a Film lives in a Films table and both tables had autoincrement primary keys then you could prepend the ID with the type, e.g. film:{ID} or person:{ID} to ensure the ID is globally unique. Luckily for us, each node is identified by its URL on https://swapi.dev and hence has a globally unique ID already.

Finally, let’s go ahead and add the node field to our Query:

#[graphql_object(context = Context)]
impl Query {
fn film(context: &Context, id: ID) -> Option<Film> {
Self::film(context, id)
}

fn person(context: &Context, id: ID) -> Option<Person> {
Self::person(context, id)
}

fn node(context: &Context, id: ID) -> Option<NodeValue> {
Self::node(context, id)
}
}

The graphql_object fields wrap up functions on the Query struct allowing us to reuse logic from the other fields:

impl Query {
fn film(context: &Context, id: ID) -> Option<Film> {
...
}

fn person(context: &Context, id: ID) -> Option<Person> {
...
}

fn node(context: &Context, id: ID) -> Option<NodeValue> {
if id.to_string().contains("people") {
Self::person(context, id).map(NodeValue::Person)
} else {
Self::film(context, id).map(NodeValue::Film)
}
}
}

Again, we’re taking some shortcuts to quickly get something to work with. We’ll do proper pattern matching on these structs next.

A Brief Detour on graphql_object

You may be wondering:

  1. Why have two impl blocks on Query with the same functions?
  2. How is that even possible?

If we use cargo expand to expand the macro we can see that the functions are actually transformed into match cases on a resolve_field function:

impl<__S> ::juniper::GraphQLValue<__S> for Query
where
__S: ::juniper::ScalarValue,
{
type Context = Context;
type TypeInfo = ();
fn type_name<'__i>(&self, info: &'__i Self::TypeInfo) -> Option<&'__i str> {
<Self as ::juniper::GraphQLType<__S>>::name(info)
}
#[allow(unused_variables)]
#[allow(unused_mut)]
fn resolve_field(
&self,
_info: &(),
field: &str,
args: &::juniper::Arguments<__S>,
executor: &::juniper::Executor<Self::Context, __S>,
) -> ::juniper::ExecutionResult<__S> {
match field {
"film" => {
...
},
"person" => {
...
},
"node" => {
...
},
}
}
}

Hence, if we want to be able to share code we need to add them to our own impl block and there’s no risk of name clashes.

Cleaning It Up

I’ve subjected you to some pretty hacky code but we’ve quickly seen how easy it is to actually go ahead and add the Node interface. Let’s clean up the code by deserializing the JSON into proper structs and using pattern matching.

To make life a bit simpler, I updated the scripts/download.js to add an id field (duplicating url) and added a type field so that we could deserialize these into an enum:

 const resources = [
- 'people',
- 'starships',
- 'vehicles',
- 'species',
- 'planets',
- 'films',
+ ['people', 'Person'],
+ ['starships', 'Starship'],
+ ['vehicles', 'Vehicle'],
+ ['species', 'Species'],
+ ['planets', 'Planet'],
+ ['films', 'Film'],
];

function replaceHttp(url) {
@@ -36,7 +36,7 @@ async function cacheResources() {
const agent = new Agent({ keepAlive: true });
const cache = {};

- for (const name of resources) {
+ for (const [name, type] of resources) {
let url = `https://swapi.dev/api/${name}/`;

while (url != null) {
@@ -46,8 +46,9 @@ async function cacheResources() {

const data = JSON.parse(replaceHttp(text));

- cache[normalizeUrl(url)] = data;
for (const obj of data.results || []) {
+ obj.type = type;
+ obj.id = obj.url;
cache[normalizeUrl(obj.url)] = obj;
}

I then added a NodeJson enum that we could deserialize into:

#[derive(Deserialize, Clone)]
#[serde(tag = "type")]
enum NodeJson {
Person(Person),
Film(Film),
Planet,
Species,
Starship,
Vehicle,
}

impl From<NodeJson> for NodeValue {
fn from(value: NodeJson) -> Self {
match value {
NodeJson::Film(film) => NodeValue::Film(film),
NodeJson::Person(person) => NodeValue::Person(person),
_ => todo!(),
}
}
}

I implemented From<Node> for NodeValue to easily convert between them. It’s a little sad that it’s basically a no op but that’s because for this example project our underlying data model is a 1–1 mapping of our Schema, which isn’t always the case and generally you want to keep your GraphQL schema and data model decoupled. For this case, I think it would be nice if Juniper let you bring your own enum instead of using a trait for an interface.

Update the type on our Context and then we’re ready to clean up the code:

#[derive(Clone)]
struct Context {
data: Arc<BTreeMap<String, NodeJson>>,
}

impl Query {
fn film(context: &Context, id: ID) -> Option<Film> {
match Self::node(context, id) {
Some(NodeValue::Film(film)) => Some(film),
_ => None,
}
}

fn person(context: &Context, id: ID) -> Option<Person> {
match Self::node(context, id) {
Some(NodeValue::Person(person)) => Some(person),
_ => None,
}
}

fn node(context: &Context, id: ID) -> Option<NodeValue> {
context
.data
.get(&id.to_string())
.cloned()
.map(|node| node.into())
}
}

Now we can deserialize the JSON into a NodeValue and we can also have the film and person fields simply wrap the node field.

Parting Thoughts

I hope you found this helpful or interesting. I’ll be writing part two on the connection interface in the next couple of days so please consider following me on medium or on X: https://twitter.com/jwebster_dev.

Please let me know what you think of the article. I’d love to know how I could improve. What should I keep doing and what can I improve?

Thanks for reading!

--

--

Jordan Webster

Passionate about improving your developer experience. I'm on a mission to learn and share as much as I can. I like Rust, Go, GraphQL and Relay. I use Vim btw.