rusty-gql

rusty-gql is a Schema First GraphQL library for Rust.

It is designed to make it easier to create a GraphQL server.

Features

  • Schema First approach
  • Code Generate from GraphQL schema
  • Convention Over Configuration

Status

rusty-gql is still an experimental project. APIs and the architecture are subject to change.

It is not yet recommended for use in production.

Getting Started

Install rusty-gql-cli

cargo install rusty-gql-cli

Run new command

rusty-gql new gql-example
cd gql-example

Start the GraphQL Server

cargo run

Creating a GraphQL Schema

rusty-gql is designed for schema first development. It reads any graphql files under schema/**.

schema/schema.graphql

type Query {
  todos(first: Int): [Todo!]!
}

type Todo {
  title: String!
  content: String
  done: Boolean!
}

Implement Resolvers

Let's edit src/graphql/query/todos.rs.


#![allow(unused)]
fn main() {
pub async fn todos(ctx: &Context<'_>, first: Option<i32>) -> Vec<Todo> {
    let all_todos = vec![
        Todo {
            title: "Programming".to_string(),
            content: Some("Learn Rust".to_string()),
            done: false,
        },
        Todo {
            title: "Shopping".to_string(),
            content: None,
            done: true,
        },
    ];
    match first {
        Some(first) => all_todos.into_iter().take(first as usize).collect(),
        None => all_todos,
    }
}
}

Generate Rust code

Edit schema.graphql.

type Query {
  todos(first: Int): [Todo!]!
  # added
  todo(id: ID!): Todo
}

type Todo {
  title: String!
  description: String
  done: Boolean!
}

rusty-gql generates rust code from graphql schema files.

rusty-gql generate // or rusty-gql g

Directory Structure

src
 ┣ graphql
 ┃ ┣ directive
 ┃ ┃ ┗ mod.rs
 ┃ ┣ input
 ┃ ┃ ┗ mod.rs
 ┃ ┣ mutation
 ┃ ┃ ┗ mod.rs
 ┃ ┣ query
 ┃ ┃ ┣ mod.rs
 ┃ ┃ ┣ todo.rs
 ┃ ┃ ┗ todos.rs
 ┃ ┣ resolver
 ┃ ┃ ┣ mod.rs
 ┃ ┃ ┗ todo.rs
 ┃ ┣ scalar
 ┃ ┃ ┗ mod.rs
 ┃ ┗ mod.rs
 ┗ main.rs

GraphQL Playground

rusty-gql supports GraphiQL playground. Open a browser to http://localhost:3000/graphiql.

Directory Structure

A rusty-gql project has the following directory structure.

rusty-gql-project
 ┣ schema
 ┃ ┗ schema.graphql
 ┣ src
 ┃ ┣ graphql
 ┃ ┃ ┣ directive
 ┃ ┃ ┃ ┗ mod.rs
 ┃ ┃ ┣ input
 ┃ ┃ ┃ ┗ mod.rs
 ┃ ┃ ┣ mutation
 ┃ ┃ ┃ ┗ mod.rs
 ┃ ┃ ┣ query
 ┃ ┃ ┃ ┣ mod.rs
 ┃ ┃ ┣ resolver
 ┃ ┃ ┃ ┣ mod.rs
 ┃ ┃ ┣ scalar
 ┃ ┃ ┃ ┗ mod.rs
 ┃ ┃ ┗ mod.rs
 ┃ ┗ main.rs
 ┗ Cargo.toml

schema

GraphQL schema files are located under schema/**.

We can also place multiple GraphQL files.

For example, like this.

schema
 ┣ post
 ┃ ┗ post.graphql
 ┣ user
 ┃ ┗ user.graphql
 ┗ index.graphql

src/graphql/query

Query resolvers.

Query

src/graphql/mutation

Mutation resolvers.

Mutation

src/graphql/resolver

GraphQL Object, Enum, Union, Interface types.

src/graphql/input

GraphQL InputObject.

InputObject

src/graphql/scalar

Custom scalars.

Scalar

src/graphql/directive

Custom directives.

Directive

Types

rusty-gql generates GraphQL types as Rust codes from schemas.

Object

rusty-gql defines GraphQL Object as Rust struct and #[GqlType] like the following.

src/graphql/resolver/todo.rs


#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct Todo {
    pub title: String,
    pub content: Option<String>,
    pub done: bool,
}

#[GqlType]
impl Todo {
    pub async fn title(&self, ctx: &Context<'_>) -> String {
        self.title.clone()
    }

    pub async fn content(&self, ctx: &Context<'_>) -> Option<String> {
        self.content.clone()
    }

    pub async fn done(&self, ctx: &Context<'_>) -> bool {
        self.done
    }
}
}

schema.graphql

type Todo {
  title: String!
  content: String
  done: Boolean!
}

We'll implement async fn for each fields with #[GqlType].

If we want to execute only when the field is included in a operation, implement async fn without the struct field.

src/graphql/resolver/todo.rs


#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct Todo {
    pub title: String,
    pub content: Option<String>,
    pub done: bool,
}

#[GqlType]
impl Todo {
    ...
    pub async fn user(&self, ctx: &Context<'_>) -> User {
      todo!()
    }
}
}
type Todo {
  title: String!
  content: String
  done: Boolean!
  user: User!
}

type User {
  name
}

Interface

GraphQL Interface is represented as Rust enum with different types and #[derive(GqlInterface), #[GqlType(interface)].

Each variants is possible types of interface.

src/graphql/resolver/pet.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;

#[derive(GqlInterface, Clone)]
pub enum Pet {
    Cat(Cat),
    Dog(Dog),
}

#[GqlType(interface)]
impl Pet {
    async fn name(&self, ctx: &Context<'_>) -> Result<String, Error> {
        match self {
            Pet::Cat(obj) => obj.name(&ctx).await,
            Pet::Dog(obj) => obj.name(&ctx).await,
        }
    }
}

}

schema.graphql

interface Pet {
  name: String
}

type Cat implements Pet {
  name: String
  meows: Boolean
}

type Dog implements Pet {
  name: String
  woofs: Boolean
}

Union

rusty-gql defines GraphQL Union as Rust enum with different types and #[derive(GqlUnion)].

src/graphql/resolver/search_result.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;

#[derive(GqlUnion)]
pub enum SearchResult {
    Human(Human),
    Droid(Droid),
}
}

schema.graphql

type Query {
  search(text: String): [SearchResult!]!
}

union SearchResult = Human | Droid

type Human {
  id: ID!
  name: String!
  homePlanet: String
}

type Droid {
  id: ID!
  name: String!
  primaryFunction: String
}

Enum

rusty-gql defines GraphQL Enum as Rust enum with #[derive(GqlEnum)].

src/graphql/resolver/episode.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;

#[derive(GqlEnum, Debug, Copy, Clone, Eq, PartialEq)]
pub enum Episode {
    NEWHOPE,
    EMPIRE,
    JEDI,
}
}

schema.graphql

enum Episode {
  NEWHOPE
  EMPIRE
  JEDI
}

InputObject

rusty-gql defines GraphQL InputObject as Rust struct with #[derive(GqlInputObject)].

src/graphql/input/review_input.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;

#[derive(GqlInputObject)]
pub struct ReviewInput {
    pub stars: i32,
    pub commentary: Option<String>,
}
}

schema.graphql

type Mutation {
  createReview(episode: Episode, review: ReviewInput!): Review
}

input ReviewInput {
  stars: Int!
  commentary: String
}

type Review {
  episode: Episode
  stars: Int!
  commentary: String
}

Scalar

We can define custom scalars.

rusty-gql represents custom scalar by using #[derive(GqlScalar)] and GqlInputType trait.

src/graphql/scalar/base64.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;

#[derive(GqlScalar)]
pub struct Base64(String);

impl GqlInputType for Base64 {
fn from_gql_value(value: Option<GqlValue>) -> Result<Self, String> {
    if let Some(GqlValue::String(v)) = value {
        let encoded = base64::encode(v);
        Ok(Base64(encoded))
    } else {
        Err(format!(
            "{}: is invalid type for Base64",
            value.unwrap_or(GqlValue::Null).to_string()
        ))
    }
}

fn to_gql_value(&self) -> GqlValue {
    GqlValue::String(self.0.to_string())
}
}
}

schema.graphql

scalar Base64

Directive

We can use directives as middleware.

It is useful in the following use cases.

  • Authorization
  • Validation
  • Caching
  • Logging, metrics
  • etc.

If we don't want to expose a specific field, we can define the following directive.

src/graphql/directive/hidden.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;

#[derive(Clone)]
struct Hidden;

impl Hidden {
    fn new() -> Box<dyn CustomDirective> {
        Box::new(Hidden {})
    }
}

#[async_trait::async_trait]
impl CustomDirective for Hidden {
    async fn resolve_field(
        &self,
        _ctx: &Context<'_>,
        _directive_args: &BTreeMap<String, GqlValue>,
        resolve_fut: ResolveFut<'_>,
    ) -> ResolverResult<Option<GqlValue>> {
      resolve_fut.await.map(|_v| None)
    }
}
}

schema.graphql

type User {
  name: String!
  password_hash: String @hidden
}
directive @hidden on FIELD_DEFINITION | OBJECT

Need to pass a HashMap of directives when Container::new in main.rs.

A Key is the directive name, a value is the directive struct.

main.rs

async fn main() {
    ...
    let mut custom_directive_maps = HashMap::new();
    custom_directive_maps.insert("hidden", Hidden::new());

    let container = Container::new(
        schema_docs.as_slice(),
        Query,
        Mutation,
        EmptySubscription,
        custom_directive_maps, // path here
    )
    .unwrap();
    ...
}

Schema

rusty-gql supports Query and Mutation. (Subscription is work in progress.)

These will be generated automatically when we create a rusty-gql project.

Query

rusty-gql has Query files under src/graphql/query/**.

For example,

src
 ┣ graphql
 ┃ ┣ query
 ┃ ┃ ┣ mod.rs
 ┃ ┃ ┗ todos.rs

src/graphql/query/todos.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;

pub async fn todos(ctx: &Context<'_>, first: Option<i32>) -> Vec<Todo> {
    let all_todos = vec![
        Todo {
            title: "Programming".to_string(),
            content: Some("Learn Rust".to_string()),
            done: false,
        },
        Todo {
            title: "Shopping".to_string(),
            content: None,
            done: true,
        },
    ];
    match first {
        Some(first) => all_todos.into_iter().take(first as usize).collect(),
        None => all_todos,
    }
}
}

src/graphql/query/mod.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;
mod todos;

#[derive(Clone)]
pub struct Query;

#[GqlType]
impl Query {
    pub async fn todos(&self, ctx: &Context<'_>, first: Option<i32>) -> Vec<Todo> {
        todos::todos(&ctx,first).await
    }
}
}

Files except for mod.rs implements resolvers for each Query fields.

mod.rs only bundles these files and defines Query struct.

Mutation

Mutation has a similar directory structure to Query.

rusty-gql has Mutation files under src/graphql/mutation/**.

src
 ┣ graphql
 ┃ ┣ mutation
 ┃ ┃ ┣ mod.rs
 ┃ ┃ ┗ create_todo.rs

src/graphql/mutation/create_todo.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;

pub async fn createTodo(ctx: &Context<'_>, input: TodoInput) -> Todo {
  ...
}
}

src/graphql/mutation/mod.rs


#![allow(unused)]
#![allow(warnings, unused)]
fn main() {
use crate::graphql::*;
use rusty_gql::*;
mod create_todo;

#[derive(Clone)]
pub struct Mutation;

#[GqlType]
impl Mutation {
    pub async fn todos(&self, ctx: &Context<'_>, input: TodoInput) -> Todo {
        create_todo::createTodo(ctx, input).await
    }
}
}

Mutation is optional, so if we don't need Mutation, use EmptyMutation struct in main.rs

main.rs

mod graphql;
...

#[tokio::main]
async fn main() {
    ...
    let container = Container::new(
        schema_docs.as_slice(),
        Query,
        EmptyMutation, // or graphql::Mutation
        EmptySubscription,
        Default::default(),
    )
    .unwrap();
}

Error Handling

If errors occur while GraphQL operation, errors field will be included in the response.

Add errors by using add_error of Context.

A error is defined by GqlError struct.


#![allow(unused)]
fn main() {
pub async fn todos(ctx: &Context<'_>, first: Option<i32>) -> Vec<Todo> {
    let all_todos = vec![
        Todo {
            title: "Programming".to_string(),
            content: Some("Learn Rust".to_string()),
            done: false,
        },
        Todo {
            title: "Shopping".to_string(),
            content: None,
            done: true,
        },
    ];
    match first {
        Some(first) => {
          if first > 30 {
            // add error
            ctx.add_error(&GqlError::new("Up to 30 items at one time.", Some(ctx.item.position)));
            all_todos
          } else {
            all_todos.into_iter().take(first as usize).collect(),
          }
        }
        None => all_todos,
    }
}
}

When we want to add a meta info, use extensions.


#![allow(unused)]
fn main() {
ctx.add_error(
    &GqlError::new("Error happens", Some(ctx.item.position)).set_extentions(
        GqlTypedError {
            error_type: GqlErrorType::Internal,
            error_detail: Some("Internal Error".to_string()),
            origin: None,
            debug_info: None,
            debug_uri: None,
        },
    ),
);
}

The GraphQL definition of rusty-gql error is as follows. Also see GraphQL spec.

type GqlError {
  message: String!
  locations: [Location!]!
  path: [String!]!
  extensions: GqlTypedError
}

type GqlTypedError {
  errorType: GqlErrorType!
  errorDetail: String
  origin: String
  debugInfo: DebugInfo
  debugUri: String
}

enum GqlErrorType {
  BadRequest
  FailedPreCondition
  Internal
  NotFound
  PermissionDenied
  Unauthenticated
  Unavailable
  Unknown
}

Roadmap

The following features will be implemented.

  • Subscription
  • Dataloader
  • Calculate Query complexity
  • Apollo tracing
  • Apollo Federation
  • Automatic Persisted Query
  • etc.