Skip to content

keepworks/graphql-sugar

Repository files navigation

GraphQL::Sugar

A sweet, extended DSL written on top of the graphql-ruby gem.

Looking for a quick overview of this gem in action? Head over to the Usage section.

This gem allows you to:

  • Easily write object types and input types that are backed by ActiveRecord models.
    • Automatically convert field names to snake_case.
    • Automatically add id, createdAt and updatedAt fields if these columns exist in your database schema.
    • Automatically determine the type of the field, based on your database schema and model validation rules, keeping things DRY.
  • Easily write resolvers and mutators to encapsulate query and mutation logic.
    • Provide an object-oriented layer, allowing easy refactoring of common code across queries and mutations.
    • Look like (and function very similar to) Rails controllers, so that writing them is a breeze.

Installation

gem 'graphql'
gem 'graphql-sugar'

And then execute:

$ bundle

And finally, do some initial setup:

$ rails g graphql:sugar

Usage

This section provides a quick overview of the how simple the DSL can be, as well as a general workflow to follow:

Writing Queries

Create the ObjectType:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  attribute :title
  attribute :content
  attribute :isPublic

  relationship :user
  relationship :comments
end

Create a Resolver:

class PostResolver < ApplicationResolver
  parameter :id, !types.ID

  def resolve
    Post.find(params[:id])
  end
end

Expose the Resolver:

Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'

  resolver :post
end

Writing Mutations

Create the InputObjectType:

Inputs::PostInputType = GraphQL::InputObjectType.define do
  name 'PostInput'

  model_class Post

  parameter :title
  parameter :content
end

Create a Mutator:

class CreatePostMutator < ApplicationMutator
  parameter :input, !Inputs::PostInputType

  type !Types::PostType

  def mutate
    Post.create!(params[:input])
  end
end

Expose the Mutator:

Types::MutationType = GraphQL::ObjectType.define do
  name 'Mutation'

  mutator :createPost
end

Usage

Object Types

Start by generating an ObjectType as you normally would:

$ rails g graphql:object Post

This would create the following under app/graphql/types/post_type.rb:

Types::PostType = GraphQL::ObjectType.define do
  name "Post"
end

Replace the name line with a model_class declaration:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post
end

This automatically sets the name as PostType. If you wish to overwrite the name, you can pass a second argument:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post, 'PostObject'
end

The model_class declaration is required to use rest of the extended ObjectType DSL (like attributes, attribute, relationships, relationship, etc). If you forget to declare it however, a helpful exception is raised. 😄

Defining attributes

Normally, this is how you would add a couple of fields to your ObjectType:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  field :id, !types.ID
  field :title, !types.String
  field :content, types.String
  field :isPublic, !types.Boolean, property: :is_public
  field :createdAt
  field :updatedAt
end

However, using GraphQL::Sugar, you can now shorten this to:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  attribute :title
  attribute :content
  attribute :isPublic
end

Under the hood:

  • The id, createdAt and updatedAt fields are automatically added if your model has those attributes.
  • The type for the rest of the fields are automatically determined based on your schema.rb and model validations. (Read more about automatic type resolution.)
  • The fields automatically resolve to the snake_cased method names of the attribute name provided (eg. isPublic => is_public).

You can shorten this further active_model_serializers-style:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  attributes :title, :content, :isPublic
end

Or even more simply:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  attributes
end

... which automatically includes all the attributes of a model based on your schema. While NOT recommended for production, this provides easy scaffolding of model-backed object types during development.

Internally attribute just defines a field, but automatically determines the type and resolves to the model's snake_cased attribute. For simplicity, it follows the exact same syntax as field, so you can override type or specify a resolve: function:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  attribute :thumbnail, types.String, resolve: ->(obj, args, ctx) { obj.picture_url(:thumb) }
end

This is useful (and necessary) if you wish to expose attr_accessors defined in your model. (Read more about automatic type resolution.)

Side Note: You can always mix in good ol' fields along with attributes if you really need to access the old DSL:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  attribute :title
  field :isArchived, types.Boolean, resolve: ->(obj, args, ctx) { obj.is_archived? }
end

However, since the syntax is pretty much the same, it is preferable to use either field or attribute throughout the type definition for the sake of uniformity. You may have a non-model backed ObjectType for example, which can use fields.

Defining relationships

Assume the Post model has the following associations:

class Post < ApplicationRecord
  belongs_to :user
  has_many :comments
end

Normally, this is how you would define the relationship in your ObjectType:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  field :userId, !types.ID, property: :user_id
  field :user, Types::UserType

  field :comments, !types[Types::CommentType]
end

However, using GraphQL::Sugar, you can now shorten this to:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  relationship :user
  relationship :comments
end

Under the hood:

  • If the relationship is belongs_to, it automatically defines a field for the corresponding foreign key. It also determines the type and marks the association as non-null using automatic type resolution.
  • If the relationship is has_one or has_many, it first looks for a corresponding Resolver (eg. in this case, CommentsResolver). If it doesn't find one, it defaults to calling method of the underlying association on the object (eg. obj.comments)

You can shorten the above code to:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  relationships :user, :comments
end

Or even more simply:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  relationships
end

... which automatically reflects on all your model associations and includes them. While NOT recommended for production, this provides easy scaffolding of model-backed object types during development.

Side Note: Unlike attribute, relationship is not just syntactic sugar for field and it does much more. It is recommended that you revert to using fields (rather than attribute) if you need to achieve a specific behavior involving associations. For example:

Types::PostType = GraphQL::ObjectType.define do
  model_class Post

  relationship :user

  field :recentComments, !types[Types::CommentType], resolve: ->(obj, args, ctx) {
    obj.comments.not_flagged.recent.limit(3)
  }
  end
end

Automatic Type Resolution

Your model attribute's type is automatically determined using Rails' reflection methods, as follows:

  • First, we look at the column type:
    • :integer gets mapped to types.Int (GraphQL::INT_TYPE),
    • :float and :decimal get mapped to types.Float (GraphQL::FLOAT_TYPE),
    • :boolean gets mapped to types.Boolean (GraphQL::BOOLEAN_TYPE),
    • and the rest get mapped to types.String (GraphQL::STRING_TYPE).
  • Then, we determine the non-nullability based on whether:
    • You have specified null: false for the column in your schema, or
    • You have specified presence: true validation for the attribute in your model.

In instances where a type cannot be automatically determined, you must provide the type yourself. For example, attr_accessors are not persisted and don't have a corresponding column in your database schema.

Input Types

Normally, this is how you would define your InputObjectType:

Inputs::PostInputType = GraphQL::InputObjectType.define do
  name 'PostInput'

  argument :title, types.String
  argument :content, types.String
  argument :isPublic, types.Boolean, as: :is_public
end

However, using GraphQL::Sugar, you can now shorten this to:

Inputs::PostInputType = GraphQL::InputObjectType.define do
  name 'PostInput'

  model_class 'Post'

  parameter :title
  parameter :content
  parameter :isPublic
end

Under the hood,

  • parameter uses the same automatic type resolution as attribute, but creates arguments that are not-null by default. The default behavior passes all values to be validated in the model instead, in order to return proper error messages in the response. (TODO: Allow this behavior to be configured via an initializer.)
  • It allows sets the :as value to the snake_cased form of the provided name. (eg. :isPublic => :is_public). This allows us to easily pass them into ActiveRecord's create and update_attributes methods.

You can override the type to make a field non-null as follows:

Inputs::PostInputType = GraphQL::InputObjectType.define do
  name 'PostInput'

  model_class 'Post'

  parameter :title, !types.String
  parameter :content
end

Resolvers

In its simplest form, a Resolver simply inherits from ApplicationResolver and contains a #resolve method.

class PostsResolver < ApplicationResolver
  def resolve
    Post.all
  end
end

To expose the resolver as a field, declare it in your root QueryType:

Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'

  resolver :posts
end

To declare arguments, you can use the parameter keyword which follows the same syntax:

class PostResolver < ApplicationResolver
  parameter :id, !types.ID

  def resolve
    Post.find(params[:id])
  end
end

The benefit is that all parameters (read: arguments) are loaded into a params object, with all keys transformed into snake_case. This allows them to be easily used with ActiveRecord methods like where and find_by.

You also have object and context available in your resolve method:

class PostsResolver < ApplicationResolver
  def resolve
    (object || context[:current_user]).posts
  end
end

Thinking in Graphs using Resolvers

Assume the following GraphQL query ("fetch 10 posts, along with the authors and 2 of their highest rated posts."):

query {
  posts(limit: 10) {
    title
    content

    user {
      name

      posts(limit: 2, sort: "rating_desc") {
        title
        rating
      }
    }
  }
}

When executed, we resolve both the first and second posts using PostsResolver. This means:

  1. All the arguments (or parameters) available to your top level posts are available to all your nested postss through relationships without any extra work.

  2. The object value passed to your PostsResolver#resolve function is very important. This would be a good place to perform an authorization check to see if the current user has access to this relationship on the object.

A quick detour: At the top of your graph, you have your root_value (read more), which the graphql-ruby library allows you to set for your schema. By default, this is null. You can either explicitly set this root_value, or implicitly consider to be the current user (or current organization, or whatever your application deems it to be).

For example,

class PostsResolver < ApplicationResolver
  def resolve
    parent_object = (object || context[:current_user])
    authorize! :view_posts, parent_object

    parent_object.posts
  end
end

Mutators

In its simplest form, a Mutator simply inherits from ApplicationMutator and contains a #mutate method:

class CreatePostMutator < ApplicationMutator
  parameter :input, !Inputs::PostInputType

  type !Types::PostType

  def mutate
    Post.create!(params[:input])
  end
end

To expose the mutator as a field, declare it in your root MutationType:

Types::MutationType = GraphQL::ObjectType.define do
  name 'Mutation'

  mutator :createPost
end

Just like resolvers, you have access to object, params and context:

class UpdatePostMutator < ApplicationMutator
  parameter :id, !types.ID
  parameter :input, !Inputs::PostInputType

  type !Types::PostType

  def mutate
    post = context[:current_user].posts.find(params[:id])
    post.update_attributes!(params[:input])
    post
  end
end

Organizing Your Code

When you install the gem using rails g graphql:sugar, it creates the following files:

app/graphql/functions/application_function.rb
app/graphql/resolvers/application_resolver.rb
app/graphql/mutators/application_mutator.rb

All your resolvers inherit from ApplicationResolver and all your mutators inherit from ApplicationMutator, both of which in turn inherit from ApplicationFunction. You can use these classes to write shared code common to multiple queries, mutations, or both.

Applying OO principles

Pagination and Sorting: You can easily create methods that enable common features.

class ApplicationResolver < ApplicationFunction
  include GraphQL::Sugar::Resolver

  def self.sortable
    parameter :sort, types.String
    parameter :sortDir, types.String
  end
end

Use in your other resolvers:

class PostsResolver < ApplicationResolver
  sortable

  def resolve
    # ...
  end
end

Shared Code: You can also easily share common code across a specific set of mutators. For example, your CreatePostMutator and UpdatePostMutator could inherit from PostMutator, which inherits from ApplicationMutator.

Tips for Large Applications

In a large app, you can quite easily end up with tons of mutations. During setup, GraphQL::Sugar adds a few lines to your eager_load_paths so you can group them in folders, while maintaining mutations at the root level. For example,

# Folder Structure
app/graphql/mutators/
- posts
  - create_post_mutator.rb
  - update_post_mutator.rb
- users
  - create_user_mutator.rb
  - update_user_mutator.rb
- application_mutator.rb
Types::MutationType = GraphQL::ObjectType.define do
  name 'Mutation'

  mutator :createPost
  mutator :updatePost

  mutator :createUser
  mutator :updateUser
end

Generators

A few basic generators have been written to quickly create some of the boilerplate code. They may not work perfectly, and the generated code may require further editing.

$ rails g graphql:resolver BlogPosts

Creates a BlogPostsResolver class at app/graphql/resolvers/blog_posts_resolver.rb.

$ rails g graphql:mutator CreateBlogPost

Creates a CreateBlogPostMutator class under app/graphql/mutators/create_blog_post_mutator.rb.

Credits

Many thanks to the work done by the authors of the following gems, which this gem uses as a foundation and/or inspiration:


Maintained and sponsored by KeepWorks.

KeepWorks

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/keepworks/graphql-sugar. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.