Source code odyssey: GraphQL Ruby
Recently I am working with GraphQL on a day-to-day basis. The more I work with it, the more I like the GraphQL API compared to the traditional Restful API. And it is an interesting project, this article is going to do a deep dive on the ruby implementation of GraphQL and share some discoveries along the way.
GraphQL is developed by Facebook (Now Meta), during the development of the new version of Facebook, they found a problem in Restful API that the API can not adapt to the rapid change of client, either it fetches too much data that the client does not need, or it does not have the data it needs for rendering. Therefore they have the idea to create an API specific scripting language to describe what API can provide, what the client wants and execute to give the exact data the client needed in a declarative way, for example:
1 | query { |
The GraphQL query start from a query root, and declare the data fields
it needs from query root, in this example we query the user
field with argument id:1
, and after the field we can select more fields that we want to query from the return type, and it can be nested like a graph. The API provides a schema so you know what is the valid fields for each types.
1 | schema { |
This defines the types and data that the API provides. So the user can fabricate the query to fetch exactly the data they need for the client.
So how is the GraphQL query executed in the backend? It is similar to how interpreters run scripting language since GraphQL is a mini scripting language itself.
Lifecycle
As a scripting language, the query is passed to the API by a POST request, and executed in the backend in following sequence:
- Tokenize:
GraphQL::Language::Lexer
splits the string into a stream of tokens - Parse:
GraphQL::Language::Parser
builds an abstract syntax tree (AST) out of the stream of tokens - Validate:
GraphQL::StaticValidation::Validator
validates the incoming AST as a valid query for the schema - Analyze: If there are any query analyzers, they are run with
GraphQL::Analysis.analyze_query
- Execute: The query is traversed,
resolve
functions are called and the response is built - Respond: The response is returned as a Hash
Tokenize and Parse
Like every programming language. Before we execute the GraphQL query, we need to parse it to create an Abstract Syntax Tree. In GraphQL-Ruby, the parser is implemented by racc. Which provides a syntax to generate Ruby compiler to parse another language.
In GraphQL ruby, the entry point is GraphQL.parse(query)
which invoke the GraphQL::Language::Parser.parse
, which generated by parser.y
parser.y
define the GraphQL grammer rules, tokenize the query by GraphQL::Language::Lexer.tokenize(graphql_string)
and implement make_node
method to create AST:
1 | def make_node(node_name, assigns) |
This method is the main action to execute in the parser, which creates nodes and Abstract Syntax Tree by node_name
. node_name
is defined in the rule, includes Field
, OperationDefinition
, TypeName
, Argument
, etc… For example, from the query in the example we can create the AST like this:
1 | GraphQL.parse(query) |
From the output we can see the basic layout of the AST, each node has a set of children_methods
that differ by type of nodes. For Field
, it is arguments
, name
, directives
, and selections
, and can call Field#children
to retrieve them for recursion. Also, the node has a #scalar_methods
method for comparison and #merge
for manipulating nodes.
For more details about programming language in general, includes Tokenize and Parsing. I suggest the ebook: create your programming language or the MAL project.
Validate and Analyze
After we have the AST and save it in the GraphQL::Query
object, the next step is to validate the AST is valid for the schema, and analyze the complexity and information of the AST.
We call GraphQL::StaticValidation::Validator#validate
with the Query object, and call the visitor GraphQL::StaticValidation::BaseVisitor
which is a depth-first traversal visitor that include the rules in GraphQL::StaticValidation::ALL_RULES
that defines all validation rules like: FieldsAreDefinedOnType
, FragmentNamesAreUnique
. The visitor will traverse the AST, when it visits a node, it will run the corresponding method like on_field
, on_argument
that is defined in rules, and return the Query is valid or not in the end.
After validation, we run the query_analyzer
defined in the Schema and use the GraphQL::Analysis::AST::Visitor
to analyze the query AST. Basically, it works similar to GraphQL::StaticValidation::BaseVisitor
but it can carry analyzers and call the analyzer methods when visiting nodes. For example, the QueryDepth
analyzer looks like this:
1 | class QueryDepth < Analyzer |
Which is called by GraphQL::Analysis::AST.analyze_query
to return the results. By default it only records errors, but we can extend the analyzer to log the result.
Schema and Types
After analyzing the query, we can start executing the query on the schema we defined. But before that, we need to explain how the schema is defined in the graphql-ruby gem. There are 3 main classes in the schema: GraphQL::Schema
, GraphQL::Schema::Object
and GraphQL::Schema::Field
.
GraphQL::Schema
is the root of the schema, it provides an interface to execute the query, contains the root types for exposing the application, query analyzers, and execution strategies. There are 3 root types for query
, mutation
, and subscription
queries.
The type is a GraphQL::Schema::Object
class that contains GraphQL::Schema::Field
that describes the interface for selecting the data, like field name, data type, arguments, and also contain resolve_proc
or resolver
that holds the logic for how to resolve the field.
From the initialize method of GraphQL::Schema::Object
:
1 | def initialize(object, context) |
A type object instance has an inner object, the type acts like a proxy. When resolving the field, it will first check if the field has a resolver, then check the type has the method same as the field name defined, if not, then delegate the call to the inner object, if the inner object is hash, use hash fetch method instead.
The object value is the result of the parent field call, or for the root type like query
or mutation
, it is specified as root_value
and passed when executing the query.
The schema for the example above looks like this:
1 | class MySchema < GraphQL::Schema |
Each type of object has an attribute called own_fields
. When we declare field :name, String, null: false
in type, we add a field instance with the options to the class. The field store the type information and how to resolve the query selection from type. We can get the field instance by get_field
method.
1 | > User.fields |
And you can resolve the field by calling the #resolve
method on field
1 | > context = OpenStruct.new({ schema: MySchema }) |
The resolve
method will find any resolver class or proc then check the method with field name on type, and fallback to object. If the object is a hash, it will use the field name as a key instead. A simplified version looks like this:
1 | def resolve(object, args, ctx) |
Execute
After we understand the structure of schema and query AST, the execute part should just match those 2 together… which I would like to say but it is actually much more complicated than this. Part of this is because there are a lot of cases and features that need to be handled, like multiplex, directives, fragments… etc, but also I wouldn’t say the algorithm is optimized in the execution logic.
From the entry point: GraphQL::Schema.execute
, it will trigger the query_execution_strategy
, which is GraphQL::Execution::Interpreter
for GraphQL 2.0.
From here, we call GraphQL::Execution::Interpreter#evaluate
, it calls GraphQL::Execution::Interpreter::Runtime#run_eager
, which is the actual class that execute query.
First, we fetch the root operation type (query, mutation, subscription) and root type, and initialize root type by calling the authorized_new
method which calls #authorized?
method on type to make sure the object is accessible.
After the params and type is set, it gathers the root selections from the query, and resolve the directives by calling resolve_with_directives
, then gather selections, evaluates the selections by evaluate_selection_with_args
, It gets the field definition from type, call field#resolve
with arguments. After the field is resolved, if the result values, then set the result, else continue on the return type and pass the next selections fields on result for iterate. It resolves query as depth search first and merges the result of the fields to GraphQL result.
With the simplified version, it looks like this (not runnable code, just extract details from source) to demonstrate how the execution run:
1 | class GraphQL::Execution::Interpreter::Runtime |
Afterward, the query response will be sent to the client and end the lifecycle.
Conclusion
Hope this step by steps introduction can help you understand how GraphQL works underhood in general. Creating a DSL for API is a pretty complicated and aggressive idea. But Facebook executes it pretty well and I think more and more GraphQL API will come out and might become the de-facto standard of Web API, just like React. I think we can learn from this and try to find more problems that can be solved by an elegant DSL.
Comments