Cómo construir potentes servidores GraphQL con Rust

Configurar un servidor GraphQL con Rust, Juniper, Diesel y Actix; aprendiendo sobre los marcos web de Rust y las poderosas macros en el camino.

Código fuente: github.com/iwilsonq/rust-graphql-example

El servicio de aplicaciones a través de GraphQL se está convirtiendo rápidamente en la forma más fácil y efectiva de entregar datos a los clientes. Ya sea que esté en un dispositivo móvil o un navegador, proporciona los datos solicitados y nada más.

Las aplicaciones cliente ya no necesitan unir información de fuentes de datos independientes. Los servidores GraphQL están a cargo de la integración, eliminando la necesidad de datos en exceso y solicitudes de datos de ida y vuelta.

Por supuesto, esto implica que el servidor tiene que manejar la agregación de datos de diferentes fuentes, como servicios de backend propios, bases de datos o API de terceros. Esto puede requerir muchos recursos, ¿cómo podemos optimizar el tiempo de la CPU?

Rust es una maravilla de un lenguaje, que combina el rendimiento crudo de un lenguaje de bajo nivel como C con la expresividad de los lenguajes modernos. Enfatiza la seguridad del tipo y la memoria, especialmente cuando hay carreras de datos potenciales en operaciones concurrentes.

Veamos qué implica construir un servidor GraphQL con Rust. Vamos a aprender sobre

  • Servidor Juniper GraphQL
  • Marco web Actix integrado con Juniper
  • Diesel para consultar una base de datos SQL
  • Macros de Rust útiles y rasgos derivados para trabajar con estas bibliotecas

Tenga en cuenta que no entraré en detalles sobre la instalación de Rust o Cargo. Este artículo asume algunos conocimientos preliminares de la cadena de herramientas de Rust.

Configurar un servidor HTTP

Para comenzar, necesitamos inicializar nuestro proyecto con cargoy luego instalar dependencias.

 cargo new rust-graphql-example cd rust-graphql-example 

El comando de inicialización arranca nuestro archivo Cargo.toml que contiene las dependencias de nuestros proyectos, así como un archivo main.rs que tiene un ejemplo simple de "Hello World".

 // main.rs fn main() { println!("Hello, world!"); } 

Como prueba de cordura, no dude cargo runen ejecutar el programa.

Instalar las bibliotecas necesarias en Rust significa agregar una línea que contiene el nombre de la biblioteca y el número de versión. Actualicemos las secciones de dependencias de Cargo.toml así:

 # Cargo.toml [dependencies] actix-web = "1.0.0" diesel = { version = "1.0.0", features = ["postgres"] } dotenv = "0.9.0" env_logger = "0.6" futures = "0.1" juniper = "0.13.1" serde = "1.0" serde_derive = "1.0" serde_json = "1.0" 

Este artículo cubrirá la implementación de un servidor GraphQL usando Juniper como la biblioteca GraphQL y Actix como el servidor HTTP subyacente. Actix tiene una API muy buena y funciona bien con la versión estable de Rust.

Cuando se agreguen esas líneas, la próxima vez que el proyecto se compile, incluirá esas bibliotecas. Antes de compilar, actualice main.rs con un servidor HTTP básico, manejando la ruta del índice.

 // main.rs use std::io; use actix_web::{web, App, HttpResponse, HttpServer, Responder}; fn main() -> io::Result { HttpServer::new(|| { App::new() .route("/", web::get().to(index)) }) .bind("localhost:8080")? .run() } fn index() -> impl Responder { HttpResponse::Ok().body("Hello world!") } 

Las dos primeras líneas en la parte superior traen el módulo que necesitamos al alcance. La función principal aquí devuelve un io::Resulttipo, lo que nos permite usar el signo de interrogación como una forma abreviada de manejar los resultados.

El enrutamiento y la configuración del servidor se crea en la instancia de App, que se crea en un cierre proporcionado por el constructor del servidor HTTP.

La ruta en sí es manejada por la función de índice, cuyo nombre es arbitrario. Siempre que esta función se implemente correctamente, Responderse puede usar como parámetro para la solicitud GET en la ruta del índice.

Si estuviéramos escribiendo una API REST, podríamos continuar agregando más rutas y controladores asociados. Pronto veremos que estamos intercambiando una lista de controladores de ruta por objetos y sus relaciones.

Ahora presentaremos la biblioteca GraphQL.

Creando el esquema GraphQL

En la raíz de cada esquema GraphQL hay una consulta raíz. Desde esta raíz podemos consultar listas de objetos, objetos específicos y cualquier campo que puedan contener.

Llame a esto QueryRoot, y lo denotaremos con el mismo nombre en nuestro código. Dado que no vamos a configurar una base de datos ni ninguna API de terceros, codificaremos los pocos datos que tenemos aquí.

Para agregar un poco de color a este ejemplo, el esquema representará una lista genérica de miembros.

En src, agregue un nuevo archivo llamado graphql_schema.rs junto con el siguiente contenido:

 // graphql_schema.rs use juniper::{EmptyMutation, RootNode}; struct Member { id: i32, name: String, } #[juniper::object(description = "A member of a team")] impl Member { pub fn id(&self) -> i32 { self.id } pub fn name(&self) -> &str { self.name.as_str() } } pub struct QueryRoot; #[juniper::object] impl QueryRoot { fn members() -> Vec { vec![ Member { id: 1, name: "Link".to_owned(), }, Member { id: 2, name: "Mario".to_owned(), } ] } } 

Junto con nuestras importaciones, definimos nuestro primer objeto GraphQL en este proyecto, el miembro. Son seres simples, con una identificación y un nombre. Más adelante pensaremos en campos y patrones más complicados.

Después de eliminar el QueryRoottipo como una estructura unitaria, podemos definir el campo en sí. Juniper expone una macro Rust llamada objectque nos permite definir campos en los diferentes nodos a lo largo de nuestro esquema. Por ahora, solo tenemos el nodo QueryRoot, por lo que expondremos un campo llamado miembros en él.

Las macros de Rust suelen tener una sintaxis inusual en comparación con las funciones estándar. No solo toman algunos argumentos y producen un resultado, sino que se expanden a un código mucho más complejo en tiempo de compilación.

Exponiendo el esquema

Debajo de nuestra llamada macro para crear el campo de miembros, definiremos el RootNodetipo que expondremos en nuestro esquema.

 // graphql_schema.rs pub type Schema = RootNode<'static, QueryRoot, EmptyMutation>; pub fn create_schema() -> Schema { Schema::new(QueryRoot {}, EmptyMutation::new()) } 

Debido al tipo fuerte de Rust, nos vemos obligados a proporcionar el argumento del objeto de mutación. Juniper expone una EmptyMutationestructura solo para esta ocasión, es decir, cuando queremos crear un esquema de solo lectura.

Ahora que el esquema está preparado, podemos actualizar nuestro servidor en main.rs para manejar la ruta "/ graphql". Dado que tener un patio de recreo también es bueno, agregaremos una ruta para GraphiQL, el patio de recreo interactivo de GraphQL.

 // main.rs #[macro_use] extern crate juniper; use std::io; use std::sync::Arc; use actix_web::{web, App, Error, HttpResponse, HttpServer}; use futures::future::Future; use juniper::http::graphiql::graphiql_source; use juniper::http::GraphQLRequest; mod graphql_schema; use crate::schema::{create_schema, Schema}; fn main() -> io::Result { let schema = std::sync::Arc::new(create_schema()); HttpServer::new(move || { App::new() .data(schema.clone()) .service(web::resource("/graphql").route(web::post().to_async(graphql))) .service(web::resource("/graphiql").route(web::get().to(graphiql))) }) .bind("localhost:8080")? .run() } 

You'll notice I've specified a number of imports that we will be using, including the schema we've just created. Also see that:

  • we call create_schema inside an Arc (atomically reference counted), to allow shared immutable state across threads (cooking with ? here I know)
  • we mark the closure inside HttpServer::new with move, indicating that the closure takes ownership of the inner variables, that is, it gains a copy of schema
  • schema is passed to the data method indicating that it is to be used inside the application as shared state between the two services

We must now implement the handlers for those two services. Starting with the "/graphql" route:

 // main.rs // fn main() ... fn graphql( st: web::Data, data: web::Json, ) -> impl Future { web::block(move || { let res = data.execute(&st, &()); Ok::(serde_json::to_string(&res)?) }) .map_err(Error::from) .and_then(|user| { Ok(HttpResponse::Ok() .content_type("application/json") .body(user)) }) } 

Our implementation of the "/graphql" route takes executes a GraphQL request against our schema from application state. It does this by creating a future from web::block and chaining handlers for success and error states.

Futures are analogous to Promises in JavaScript, which is enough to understand this code snippet. For a greater explanation of Futures in Rust, I recommend this article by Joe Jackson.

In order to test out our GraphQL schema, we'll also add a handler for "/graphiql".

 // main.rs // fn graphql() ... fn graphiql() -> HttpResponse { let html = graphiql_source("//localhost:8080/graphql"); HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(html) } 

This handler is much simpler, it merely returns the html of the GraphiQL interactive playground. We only need to specify which path is serving our GraphQL schema, which is "/graphql" in this case.

With cargo run and navigation to //localhost:8080/graphiql, we can try out the field we configured.

Members query in graphiql

It does seem to take a little more effort than setting up a GraphQL server with Node.js and Apollo but the static typing of Rust combined with its incredible performance makes it a worthy trade — if you're willing to work at it.

Setting up Postgres for Real Data

If I stopped here, I wouldn't even be doing the examples in the docs much justice. A static list of two members that I wrote myself at dev time will not fly in this publication.

Installing Postgres and setting up your own database belongs in a different article, but I'll walk through how to install diesel, the popular Rust library for handling SQL databases.

See here to install Postgres locally on your machine. You can also use a different database like MySQL in case you are more familiar with it.

The diesel CLI will walk us through initializing our tables. Let's install it:

 cargo install diesel_cli --no-default-features --features postgres 

After that, we will add a connection URL to a .env file in our working directory:

 echo DATABASE_URL=postgres://localhost/rust_graphql_example > .env 

Once that's there, you can run:

 diesel setup # followed by diesel migration generate create_members 

Now you'll have a migrations folder in your directory. Within it, you'll have two SQL files: one up.sql for setting up your database, the other down.sql for tearing it down.

I will add the following to up.sql:

 CREATE TABLE teams ( id SERIAL PRIMARY KEY, name VARCHAR NOT NULL ); CREATE TABLE members ( id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, knockouts INT NOT NULL DEFAULT 0, team_id INT NOT NULL, FOREIGN KEY (team_id) REFERENCES teams(id) ); INSERT INTO teams(id, name) VALUES (1, 'Heroes'); INSERT INTO members(name, knockouts, team_id) VALUES ('Link', 14, 1); INSERT INTO members(name, knockouts, team_id) VALUES ('Mario', 11, 1); INSERT INTO members(name, knockouts, team_id) VALUES ('Kirby', 8, 1); INSERT INTO teams(id, name) VALUES (2, 'Villains'); INSERT INTO members(name, knockouts, team_id) VALUES ('Ganondorf', 8, 2); INSERT INTO members(name, knockouts, team_id) VALUES ('Bowser', 11, 2); INSERT INTO members(name, knockouts, team_id) VALUES ('Mewtwo', 12, 2); 

And into down.sql I will add:

 DROP TABLE members; DROP TABLE teams; 

If you've written SQL in the past, these statements will make some sense. We are creating two tables, one to store teams and one to store members of those teams.

I am modeling this data based on Smash Bros if you have not yet noticed. It helps the learning stick.

Now to run the migrations:

 diesel migration run 

If you'd like to verify that the down.sql script works to destroy those tables, run: diesel migration redo.

Now the reason why I named the GraphQL schema file graphql_schema.rs instead of schema.rs, is because diesel overwrites that file in our src direction by default.

It keeps a Rust macro representation of our SQL tables in that file. It is not so important to know how exactly this table! macro works, but try not to edit this file — the ordering of the fields matters!

 // schema.rs (Generated by diesel cli) table! { members (id) { id -> Int4, name -> Varchar, knockouts -> Int4, team_id -> Int4, } } table! { teams (id) { id -> Int4, name -> Varchar, } } joinable!(members -> teams (team_id)); allow_tables_to_appear_in_same_query!( members, teams, ); 

Wiring up our Handlers with Diesel

In order to serve the data in our tables, we must first update our Member struct with the new fields:

 // graphql_schema.rs + #[derive(Queryable)] pub struct Member { pub id: i32, pub name: String, + pub knockouts: i32, + pub team_id: i32, } #[juniper::object(description = "A member of a team")] impl Member { pub fn id(&self) -> i32 { self.id } pub fn name(&self) -> &str { self.name.as_str() } + pub fn knockouts(&self) -> i32 { + self.knockouts + } + pub fn team_id(&self) -> i32 { + self.team_id + } } 

Note that we are also adding the Queryable derived attribute to Member. This tells Diesel everything it needs to know in order to query the right table in Postgres.

Additionally, add a Team struct:

 // graphql_schema.rs #[derive(Queryable)] pub struct Team { pub id: i32, pub name: String, } #[juniper::object(description = "A team of members")] impl Team { pub fn id(&self) -> i32 { self.id } pub fn name(&self) -> &str { self.name.as_str() } pub fn members(&self) -> Vec { vec![] } } 

In a short while, we will update the members function on Team to return a database query. But first, let us add a root call for members.

 // graphql_schema.rs + extern crate dotenv; + use std::env; + use diesel::pg::PgConnection; + use diesel::prelude::*; + use dotenv::dotenv; use juniper::{EmptyMutation, RootNode}; + use crate::schema::members; pub struct QueryRoot; + fn establish_connection() -> PgConnection { + dotenv().ok(); + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + PgConnection::establish(&database_url).expect(&format!("Error connecting to {}", database_url)) + } #[juniper::object] impl QueryRoot { fn members() -> Vec { - vec![ - Member { - id: 1, - name: "Link".to_owned(), - }, - Member { - id: 2, - name: "Mario".to_owned(), - } - ] + use crate::schema::members::dsl::*; + let connection = establish_connection(); + members + .limit(100) + .load::(&connection) + .expect("Error loading members") } } 

Very good, we have our first usage of a diesel query. After initializing a connection, we use the members dsl, which is generated from our table! macros in schema.rs, and call load, indicating that we wish to load Member objects.

Establishing a connection means connecting to our local Postgres database by using the env variable we declared earlier.

Assuming that was all input correctly, restart the server with cargo run, open GraphiQL and issue the members query, perhaps adding the two new fields.

The teams query will be very similar — the difference being we must also add a part of the query to the members function on the Team struct in order to resolve the relationship between GraphQL types.

 // graphql_schema.rs #[juniper::object] impl QueryRoot { fn members() -> Vec { use crate::schema::members::dsl::*; let connection = establish_connection(); members .limit(100) .load::(&connection) .expect("Error loading members") } + fn teams() -> Vec { + use crate::schema::teams::dsl::*; + let connection = establish_connection(); + teams + .limit(10) + .load::(&connection) + .expect("Error loading teams") + } } // ... #[juniper::object(description = "A team of members")] impl Team { pub fn id(&self) -> i32 { self.id } pub fn name(&self) -> &str { self.name.as_str() } pub fn members(&self) -> Vec { - vec![] + use crate::schema::members::dsl::*; + let connection = establish_connection(); + members + .filter(team_id.eq(self.id)) + .limit(100) + .load::(&connection) + .expect("Error loading members") } } 

When running this is GraphiQL, we get:

More complex query in graphiql

I really like the way this is turning out, but there is one more thing we must add in order to call this tutorial complete.

The Create Member Mutation

What good is a server if it is read-only and not writable? Well I'm sure those have their uses too, but we would like to write data to our database, how hard can it be?

First we'll create a MutationRoot struct that will eventually replace our usage of EmptyMutation. Then we will add the diesel insertion query.

 // graphql_schema.rs // ... pub struct MutationRoot; #[juniper::object] impl MutationRoot { fn create_member(data: NewMember) -> Member { let connection = establish_connection(); diesel::insert_into(members::table) .values(&data) .get_result(&connection) .expect("Error saving new post") } } #[derive(juniper::GraphQLInputObject, Insertable)] #[table_name = "members"] pub struct NewMember { pub name: String, pub knockouts: i32, pub team_id: i32, } 

As GraphQL mutations typically go, we define an input object called NewMember and make it the argument of the create_member function. Inside this function, we establish a connection and call the insert query on the members table, passing the entire input object.

It is super convenient that Rust allows us to use the same structs for GraphQL input objects as well as Diesel insertable objects.

Let me make this a little more clear, for the NewMember struct:

  • we derive juniper::GraphQLInputObject in order to create a input object for our GraphQL schema
  • we derive Insertable in order to let Diesel know that this struct is valid input for an insertion SQL statement
  • we add the table_name attribute so that Diesel knows which table to insert it in

There is a lot of magic going on here. This is what I love about Rust, it has great performance but the code has features like macros and derived traits to abstract away boilerplate and add functionality.

Finally, at the bottom of the file, add the MutationRoot to the schema:

 // graphql_schema.rs pub type Schema = RootNode; pub fn create_schema() -> Schema { Schema::new(QueryRoot {}, MutationRoot {}) } 

I hope that everything is there, we can test out all of our queries and mutations thus far now:

 # GraphiQL mutation CreateMemberMutation($data: NewMember!) { createMember(data: $data) { id name knockouts teamId } } # example query variables # { # "data": { # "name": "Samus", # "knockouts": 19, # "teamId": 1 # } # } 

If that mutation ran successfully, you can pop open a bottle of champagne as you are on your way to building performant and type-safe GraphQL Servers with Rust.

Thanks For Reading

I hope you have enjoyed this article, I also hope that it gave you some sort of inspiration for your own work.

If you'd like to keep up with the next time I drop an article in the realm of Rust, ReasonML, GraphQL, or software development at large, feel free to give me a follow on Twitter, dev.to, or on my website at ianwilson.io.

The source code is here github.com/iwilsonq/rust-graphql-example.

Other Neat Reading Material

Here are some of the libraries we worked with here. They have great documentation and guides as well so be sure to give them a read :)

  • Implementation of Rust Futures in Tokio
  • Juniper - GraphQL Server for Rust
  • Diesel - Safe, Extensible ORM and Query Builder for Rust
  • Actix - Rust's powerful actor system and most fun web framework