Robert Mosolgo

Prototyping a GraphQL Schema From Definition With Ruby

GraphQL 1.5.0 includes a new way to define a schema: from a GraphQL definition.

In fact, loading a schema this way has been supported for while, but 1.5.0 adds the ability to specify field resolution behavior.

GraphQL IDL

Besides queries, GraphQL has an interface definition language (IDL) for expressing a schema’s structure. For example:

1
2
3
4
5
6
7
8
9
10
11
12
schema {
  query: Query
}

type Query {
  post(id: ID!): Post
}

type Post {
  title: String!
  comments: [Comment!]
}

You can turn a definition into a schema with Schema.from_definition:

1
2
schema_defn = "..."
schema = GraphQL::Schema.from_definition(schema_defn)

(By the way, the IDL is technically in RFC stage.)

Resolvers

Schema.from_definition also accepts default_resolve: argument. It expects one of two inputs:

  • A nested hash of type Hash<String => Hash<String => #call(obj, args, ctx)>>; or
  • An object that responds to #call(type, field, obj, args, ctx)

Resolving with a Hash

When you’re using a hash:

  • The first key is a type name
  • The second key is a field name
  • The last value is a resolve function (#call(obj, args, ctx))

To get started, you can write the hash manually:

1
2
3
4
5
6
7
8
9
10
{
  "Query" => {
    "post" => ->(obj, args, ctx) { Post.find(args[:id]) },
  },
  "Post" => {
    "title" => ->(obj, args, ctx) { obj.title },
    "body" => ->(obj, args, ctx) { obj.body },
    "comments" => ->(obj, args, ctx) { obj.comments },
  },
}

But you can also reduce a lot of boilerplate by using a hash with default values:

1
2
3
4
5
6
7
8
9
10
11
12
# This hash will fall back to default implementation if another value isn't provided:
type_hash = Hash.new do |h, type_name|
  # Each type gets a hash of fields:
  h[type_name] = Hash.new do |h2, field_name|
    # Default resolve behavior is `obj.public_send(field_name, args, ctx)`
    h2[field_name] = ->(obj, args, ctx) { obj.public_send(field_name, args, ctx) }
  end
end

type_hash["Query"]["post"] = ->(obj, args, ctx) { Post.find(args[:id]) }

schema = GraphQL::Schema.from_definition(schema_defn, default_resolve: type_hash)

Isn’t that a nice way to set up a simple schema?

Resolving with a Single Function

You can provide a single callable that responds to #call(type, field, obj, args, ctx). What a mouthful!

The advantage of that hefty method signature is that it’s enough to specify any resolution behavior you can imagine. For example, you could create a system where type modules were found by name, then methods were called by name:

1
2
3
4
5
6
7
8
9
10
11
module ExecuteGraphQLByConvention
  module_function
  # Find a Ruby module corresponding to `type`,
  # then call its method corresponding to `field`.
  def call(type, field, obj, args, ctx)
    type_module = Object.const_get(type.name)
    type_module.public_send(field.name, obj, args, ctx)
  end
end

schema = GraphQL::Schema.from_definition(schema_defn, default_resolve: ExecuteGraphQLByConvention)

So, a single function combined with Ruby’s flexibility and power opens a lot of doors!

Doesn’t it remind you a bit of method dispatch? The arguments are:

GraphQL Field Resolution Method Dispatch
type class
field method
obj receiver
args method arguments
ctx runtime state (cf mrb_state, RedisModuleCtx, or ErlNifEnv)

Special Configurations

Some schemas need other configurations in order to run:

To add these to a schema, use .redefine:

1
2
3
4
5
# Extend the schema with new definitions:
schema = schema.redefine {
  resolve_type ->(obj, ctx) { ... }
  monitoring :appsignal
}

What’s Next?

Rails has proven that “Convention over Configuration” can be a very productive way to start new projects, so I’m interested in exploring convention-based APIs on top of this feature.

In the future, I’d like to add support for schema annotations in the form of directives, for example:

1
2
3
type Post {
  comments: [Comment!] @relation(hasMany: "comments")
}

These could be used to customize resolution behavior. Cool!