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:
-
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.
-
params
: Object contain the arguments we passed in the query likequery { menuItem(id: 12) { name } }
the params will be{ id: 12 }
-
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(), },
-
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! 😇