Ajaxtown
Published on

GraphQL - Under the hood

Authors

For those have used GraphQL will probably agree that thinking in terms of fields and relationships is more expressive that thinking in terms of endpoints. GraphQL takes care of one of the most important subjects - Validation. It does it using a schema. Schema is the representation of your API and resolvers are responsible to prepare the data that matches the schema. With the help of these two, GraphQL builds an executable schema. When a request comes in, GraphQL validates the incoming query against the schema in executable object even before it reaches the resolver. If the validation is successful, each field from the query is executed and the corresponding resolver is called to produce the next value. If a field produces a scalar value like a string or number, then the execution completes. This is then sent back to the client.

Let's dive into this and understand how each of these parts work together internally.

What is Executable Schema ?

Schema can be built in two ways.

  • SDL (Schema definition language)
  • Let's object-based representation
When you run JavaScript, it gets compiled to machine code, so that CPU can compute your code. Similarly, when you write SDL, it needs to be converted to GraphQLSchema object so that GraphQL can validate and prepare the response.

Essentially, SDL is more human-readable and is easier to understand. For e.g.

// SDL
type User {
   name: String
   email: String
}

One of the ways to make this into an executable object along with resolvers is by using GraphQLTools.

import { makeExecutableSchema } from "@graphql-tools/schema";

export const schema = makeExecutableSchema({
  typeDefs, // schema definitions
  resolvers, 
});

Once this executable schema is ready, you can run a server like Apollo to handle these requests.

import { ApolloServer } from "apollo-server-micro";
const server = new ApolloServer({ typeDefs, resolvers }); 
 // The `listen` method launches a web server. 
server.listen().then(({ url }) => {  
   console.log(`🚀 Server ready at ${url}`); 
});

Validation

One of the most important practices in software development is validating the data coming from user and this is beautifully taken care by GraphQL.

When a request is sent from a client, it may contain a query or a mutation. A query looks like this.

Query {
   user(id:2) {
      name
      email
   } 
}

Here, we are requesting the fields name and email of a user whose ID is 2.

GraphQL needs to convert this into AST (Abstract Syntax Tree) so that it can validate this query against the schema.

import { validate, parse } from "graphql";
// incoming query from the request
const query = `
  user(id:2) {  
     name  
     email  
  }
` 
const queryAST = parse(query);
const errors = validate(schema, queryAST);

if(!errors.length) {
    // validation successfull
}

It will go through another round of validation to validate the value types of each field. This is one of the best part of GraphQL.

Executing the Query

In the above section we saw how we validate the query with just one line. If there is any error, it won't even reach the execution phase. GraphQL is still going to revalidate this query in the execution phase. If it encounters any error, it will exit.

In order to execute the query, it has to reach the resolvers. The signature of a resolver may look like this.

const Query = {
    user: async (parent, args, context, info) => {
        return await db.findUser({where: {id: args.id}});
    }
}

Let's go a little deeper with the arguments of a resolver -

async (parent, args, context, info)

GraphQL needs to prepare the necessary arguments that needs to be passed to a resolver. It assures that data must be available at all points during the queries' execution.

For this, it collects all the necessary details such as fragments, operation type from the queryAST.

parent - (also called root) - GraphQL query fields can be nested. For e.g.

Query {
   user(id: 2) {
      name
      email
      posts { // <========== 
        title 
      }     
   }
}

GraphQL is going to resolve this query from top to bottom. When it reaches the posts field, it is going to forward the response it from the previous field resolution. All this information will be available in the parent argument.

args - The argument carries the parameters of the query. In the query above, it is the ID of the user.

context - I will write in detail about this in another post but essentially, you are responsible to handle this. You might want to pass some functions or data that can be used by resolvers. For e.g. session, database instance, role and permissions, etc.

info - This will contain the AST representation of the query or mutation. This requires a post of its own. For now, you may ignore this.

Field and Value Resolution

Each field in a GraphQL query can have its own resolver. This depends on how you design the schema. Let's take can example.

type User {
   name: String!
   email: String!
   posts: [Post] // <==== Optional (does not contain ! ) 
}

type Post {
   title: String!
}

In the above schema, our User resolver is only responsible to pass the values of name and email. posts can have its own resolver, which will be responsible to query the db to fetch all the posts for the user.

If we send a query without the posts field, GraphQL will avoid that additional operation of data fetching for posts from the posts resolver.

Response

Thisis the last step of the request. GraphQL will validate the value of each field in a recursive way and build the response data. It will continue this until the field is resolved to a GraphQLScalarType. Let's take the same example of the User query.

type User {
   name: String! 
   email: String!
   posts: [Post] 

type Post {
   title: String!
}

In the above query, the field (name) definition will be resolved into GraphQLScalarType, so the result is collected and stored in an object under the current field-node. This ensures that the resulting data returned for the query maintains the same order as the query.

The field resolution will continue from top to bottom, so when it will reach post, the field definition will be an instance of GraphQLObjectType. It will parse this recursively until the field it receives (in this case, title) is of GraphqlScalarType. After all the fields have resolved, it will then be sent back to the client.

I have explained the process of a query resolution but it remains the same for mutation as well.

Conclusion

In this post I have only touched the surface of how a query is processed under the hood. Below is a short summary of what we learnt.

  • Schema and Resolvers are combined to create an executable schema which is used to resolve a query.
  • Query from the client gets converted into AST in order to validate the query against the schema.
  • If the validation success, each field is resolved from top to bottom. This continues till all the field definitions are GraphQLScalarType.
  • This is then sent back to the client.

You may have more questions like what happens when it encounters an error ? Or how can we handle errors in general with GraphQL. My friend Boopathi has explained in detail in his post - Modeling Errors with graphQL.

If you have any questions, you can reach me on twitter or post a comment below.