The Full-Stack guide to GraphQL: NodeJs Server

Jawakar Durai's avatar

Jawakar Durai

GraphQL has been adopted in many products after Facebook open-sourced it in 2015. We, at Dockup lately changed our APIs to GraphQL service, and that was my job.

I thought it must be way more complex to implement a full GraphQL service for our app, but surprisingly it was easy and fun.

Thinking about how to convey what I learned with the same interest and fun, I concluded, learning by building an app will be great. So, Yeah! we'll build a small app for restaurants to add, view and delete menu items, allowing us to deep dive into different GraphQL concepts.

I couldn't do all of these in one post, so there will be a series of them:

  • Intro: you can go through my another post for some intro (optional)
  • Implementation with NodeJs as server
  • Implementation in Elixir with Absinthe GraphQL and Phoenix framework
  • React front end app connected to GraphQL

Setup

Let's start by setting up the project directory layout:

 
$ nvm install 12.16.1
$ nvm use 12.16.1
 
# make a project dir tree and cd into server dir
$ mkdir menucard && cd menucard && mkdir nodeserver elixirserver client && cd nodeserver
 
# init the project
$ yarn init

Add the packages we need:

$ yarn add graphql apollo-server nodemon
$ touch index.js

Add this to your package.json for nodemon to sync with the changes

"scripts": {
  "start": "node index.js""
  "start:dev" : "nodemon"
}

And run yarn start:dev in your terminal.

We can use the apollo-server package, which is an abstract wrapper over the graphql package.

We'll use an Array as a data source for now and connect the database later.

Query

In GraphQL, you can consider Query as a set of user-defined functions that can do what GET does in REST.

Let's assume we want to get this data:

const menuItems = [
  {
    id: 1,
    name: "Pizza",
    category: "Meal",
    price: "4.5"
  },
  {
    id: 2,
    name: "Burger",
    category: "Meal",
    price: "3.2"
  },
]

Let me write a basic index.js setup for a query and explain it:

// index.js
 
const { ApolloServer, gql } = require("apollo-server");
 
const menuItems = [
  {
    id: 1,
    name: "Pizza",
    category: "Meal",
    price: "4.5"
  },
  {
    id: 2,
    name: "Burger",
    category: "Meal",
    price: "3.2"
  },
]
 
const typeDefs = gql`
 
  """"
  Menu item represents a single menu item with a set of data
  """
  type MenuItem {
    id: ID!
    name: String!
    category: String!
    price: Float!
  }
 
  type Query {                 
 
    "Get menu Items"
    menuItems: [MenuItem]      
  }
`
const resolvers = {
  Query: {
    menuItems: () => menuItems
  }
}
 
const server = new ApolloServer({
  typeDefs,
  resolvers,
});
 
server.listen().then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`);
});
 

Okay, what's going on?

First, we are getting the services we need, ApolloServer and gql.

  • We instantiate ApolloServer to get the server running and interact with it
  • gql parses the GraphQL string which JavaScript doesn't support internally.

TypeDefs

const typeDefs = gql`
 
  """"
  Menu item represents a single menu item with a set of data
  """
  type MenuItem {
    id: ID!
    name: String!
    category: String!
    price: Float!
  }
 
  type Query {                 
 
    "Get menu Items"
    menuItems: [MenuItem]      
  }
`

typeDefs is a GraphQL schema.

A GraphQL schema is written in its own typed language: The Schema Definition Language (SDL)

We define the queries a client can do in Query type (here menuItems) and the mutations a client can do in Mutation type (we'll see later).

A type is nothing but an object containing fields, where keys are the Field names and values are the data-type of the Field. In GraphQL, every field should be typed.

And the strings you see above the fields are documentation strings. You can see the docs on the graphiql interface which we'll talk about later.

In GraphQL, data types are:

  • Scalar types:
    • Int
    • String
    • Float
    • Boolean
    • ID: Represents unique number, like id in db.
  • Object types
    • type: In the above example we are defining MenuItem as a type and use that as the data-type for menuItems field in Query type

Types can be represented as:

  • [typeName]: Array of data with the type typeName should be returned
  • typeName!: the ! in the typeName represents nonNullable i.e, the returning field should not be null
  • You can also combine these representations like [typeName!]! i.e, you should return a nonNull array with nonNull elements which matches typeName type

Resolvers

const resolvers = {
  Query: {
    menuItems: () => menuItems
  }
}

Resolver map relates the schema fields and types to a function it has to call to turn the GraphQL operations into data by fetching from any source you have. For now, it's just an array for us.

Let us add another field to menuItem: reviews with its type:

const typeDefs = gql`
  type Review {
    id: ID!
    comment: String!
    authorId: ID!
  }
 
  """"
  Menu item represents a single menu item with a set of data
  """
  type MenuItem {
    id: ID!
    name: String!
    category: String!
    price: Float!
    reviews: [Review]
  }
 
  type Query {                 
 
    "Get menu Items"
    menuItems: [MenuItem]    
  }
`

Let's assume there's some array of reviews which have the menuItem Id as a Foreign key, then the resolvers will be:

const resolvers = {
  Query: {
     menuItems: () => menuItem,
   },
  MenuItem: {
    reviews: (parent, args, context) => {
      return reviews.filter(item => item.menuItemId == parent.id)
    }
  }
}

The query

query {
  menuItems {
    reviews {
      comment  
    }
  }
}

will call Query.menuItems first and then pass it's returned value as parent to MenuItem.reviews. The result will be:

{
  data: {
    menuItems: [{
      reviews: [{
        comment: "Some comment"
      }]
    }]
  }
}

A resolver can return an Object or Promise or scalar value, this should also match the data-type defined in the schema for that field. Resolved data will be sent if a promise returned.

Every resolver function takes four arguments when it's called:

  1. parent: Object contains the returned value from the parent's resolver.

    Every GraphQL query is a tree of function calls in the server. So, every field's resolver gets the result of parent resolver, in this case:

  • query aka rootQuery is one of the top-level Parents.
  • Parent in Query.menuItem will be whatever the server configuration passed for rootQuery.
  • Parent in MainItem.reviews will be the returned value from the resolver Query.MenuItems.
  • Parent in Review.id, Review.comment and Review.authorId will be the resolved value of MenuItem.reviews.
  1. params: Object contain the arguments we passed in the query like query { menuItem(id: 12) { name } } the params will be { id: 12 }

  2. context: You can pass an object when instantiating the server and access it on every resolver.

    Example:

    const server = new ApolloServer({
      typeDefs,
      resolvers,
      context: { menuRefInContext: MenuItem }
    });
    Query: {
      menuItems: (parent, __, { menuRefInContext }) => menuTableInContext.findAll(),
    },
  3. info: This argument mostly contains the info about your schema and current execution state.

Default resolver

Every type need not have a resolver, ApolloServer provides a default resolver, that looks for relevant field name in the parent object, or call the function if we have defined one for the field explicitly.

For the following schema, comment field of the Review would not need a resolver if the result of reviews resolver returns a list of objects which are already containing a comment field.

type Review {
  comment: String!
  authorId: ID!
}
 
type MenuItem {
  reviews: [Review]
}

Start the server

Instantiate ApolloServer with typeDefs and resolvers to listen for queries.

const server = new ApolloServer({
  typeDefs,
  resolvers,
});
 
server.listen().then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`);
});

Make the query

Go to localhost:4000, enter the query on the left side of the graphiql: An interface that is provided by apollo-server to test, on the right, you can access docs of different types you have and field infos.

# This is the root operation: graphql provides three operations
#
# query
# mutation
# subscription
query {
  # endpoint with what are the values we need, here we are asking
  # for "name, price and, comment and authorId of reviews of all the menuItems"
  menuItems {
    name
    price
    reviews {
      comment
      authorId
    }
  }
}

the result will closely match the query by returning only what we asked for:

// result
 
{
  "data": {
    "menuItems": [
      {
        "name": "Pizza",
        "price": 4.5,
        "reviews" : [
          {
            comment: "Not bad",
            authorId: 12,
          }
        ]
      },
      {
        "name": "Burger",
        "price": 3.2,
        "reviews" : [
          {
            comment: "Good",
            authorId: 90,
          }
        ]
      }
    ]
  }
}

A query is like a tree of functions which fetches data. For example: If we take the above query,

You can imagine fields as pipes of functions, which could return a data or call another function. Fields with data-types Int, String, Boolean, Float and ID returns a data, but type fields will call a function, depends on its fields' data-type, returning a data or function call will happen.

| - rootQuery()
    | - menuItems()
        | - return name
        | - return price
        | - reviews()
            | - return comment
            | - return authurId

Database setup

We'll use a package called sequelize to use Postgres database with MongoDB like functions. It will also work with any other DB.

Set up a DB and get the URL to pass it as an argument to the Sequelize constructor.

Stop the app and run the below command in your terminal:

$ yarn add pg pg-hstore sequelize && yarn start:dev

We'll use sequelize to connect to database, Model a table, then interact with the table

" GraphQL is not bound to any database, doesn't interact with the database on its own, we have to do all the querying and return the data which a GraphQL query expects. "

Our Postgres database hosted on Heroku.

We'll change the existing code to use sequelize:

// index.js
 
const { ApolloServer, gql } = require("apollo-server");
const { Sequelize, DataTypes } = require("sequelize");  
 
// connect to database         
const sequelize = new Sequelize(
  "PASTE YOUR POSTGRES URL HERE"
);
 
// Expecting a table name "menuItems" with fields name, price and category,
// You'll use "MenuItem" to interact with the table. id, createdAt and
// updatedAt fields will be added automatically
const MenuItem = sequelize.define("menuItems", {
  name: {
    type: DataTypes.STRING     
  },
  price: {
    type: DataTypes.FLOAT
  },
  category: {
    type: DataTypes.STRING
  }
});  
 
const Review = sequelize.define("reviews", {
  comment: {
    type: DataTypes.String
  },
  authorId: {
    type: DataTypes.INTEGER
  }
});
 
MenuItem.hasMany(Review , { foreignKey: "menuItemId", constraints: false })
 
// `sync()` method will create/modify the table if needed, comment it when not
// needed, uncomment whenever you change the model definition.
// For production you might consider Migration (https://sequelize.org/v5/manual/migrations.html)
// instead of calling sync() in your code.
// MenuItem.sync();    
 
const typeDefs = gql`
  type Review {
    id: ID!
    comment: String!
    authorId: ID!
  }
 
  """"
  Menu item represents a single menu item with a set of data
  """
  type MenuItem {
    id: ID!
    name: String!
    category: String!
    price: Float!
    reviews: [Review]
  }
 
  type Query {                 
 
    "Get menu Items"
    menuItems: [MenuItem]
  }
`
 
// Note: We removed the separate resolver for reviews because
// menuItems itself returned reviews for each MenuItem
const resolvers = {
  Query: {
    menuItems: (parent, __, { menuItem }) => {
      return menuItem.findAll({
        include: [{ model: Review }]
      })
    },
  }
}
 
const server = new ApolloServer({
  typeDefs,
  resolvers,
});
 
server.listen().then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`);
});
 

Run the query in graphiql

query {
  menuItems {
    name
    price
    reviews {
      comment
    }
  }
}

You should get an empty array because we haven't created any menu item in the database yet, we will do that using mutation

Mutations

You can think of Mutations as user-defined functions which can do what POST, PUT, PATCH and DELETE does in REST

Change the TypeDefs to:

// index.js
 
const typeDefs = gql`
 
  type Review {
    id: ID!
    comment: String!
    authorId: ID!
  }
 
  """"
  Menu item represents a single menu item with a set of data
  """
  type MenuItem {
    id: ID!
    name: String!
    category: String!
    price: Float!
    reviews: [Review]
  }
 
  input MenuItemInput {
    name: String!
    category: String
    price: Float
  }
 
  input ReviewInput {
    comment: String!
    authorId: ID!
    menuItemId: ID!
  }
 
  type Query {                 
 
    "Get menu Items"
    menuItems: [MenuItem]      
  }
 
  type Mutation {
    addMenuItem(menuItem: MenuItemInput): MenuItem
    addReview(review: ReviewInput): Review
  }
`

Here, we introduced two new fields: Mutation and input

Mutation field

Like Query, Mutation is also a special field where we will define all mutations (the fields inside Mutation type), we will also write resolver functions for our mutations.

Input Objects

Input fields are also like type fields, but this defines types for the arguments to be passed by the client query if it is an object.

For example, the query to create a menuItem:

 
# Operation we are doing
mutation {
  addMenuItem(menuItem: { name: "Pizza", category: "Meal", price: 10.3 }) {
    name
    price
    category
  }
}

See, we are passing an object menuItem as an argument to the addMenuItem mutation, like a function. This menuItem should match the input MenuItemInput type we defined.

Resolver function

// index.js
 
const resolvers = {
 
  Query: {
     ...
  },
 
  Mutation: {
    addMenuItem: (
      _,
      { menuItem: { name, price, category } },
      __
    ) => {
      return MenuItem.create({
        name,
        price,
        category
      });
 
    },
 
    addReview: (_, { review: { comment, authorId, menuItemId } }) => {
      return Review.create({
        comment,
        menuItemId,
        authorId
      });
    },
  }
}

Sequelize functions will always return a promise, so we are returning promises returned by the functions.

Run this query:

mutation{
  addMenuItem(params:{name: "asdasd", price: 21, rating: 33}){
    id
    name
    price
  }
}

The menu item will be created in the table using the resolver function and the value returned for the query will be:

{
  "data": {
    "addMenuItem": {
      "id": "1",
      "name": "Toast",
      "price": 3
    }
  }
}

Then run this query with the returned menuItemId:

 
mutation{
  addReview(review: { comment: "not bad", authorId: 12, menuItemId: 1 }){
    id
    comment
    authorId
  }
}

The result would be:

{
  "data": {
    "addReview": {
      "id": "1",
      "comment": "not bad",
      "authorId": "12"
    }
  }
}

Did you notice that even though we didn't define id in the models we got the result with id? It is because sequelize automatically added it to the table when creating the record.

We can also pass arguments as individual values,

mutation {
  addMenuItem(name: "Pizza", category: "Meal", price: 10.3) {
    ...
  }

It's all about how we get the values from parameters in the resolver function.

That's it from my side. You have exercises on creating a query getSingleMenuItem and a mutation deleteMenuItem.

See you in the next post: How to implement these with Elixir and Phoenix.

Good Luck! 😇