
Creating an API with Node.js using GraphQL

This article marks the latest chapter in our GraphQL series. In the first part of our series, we went through Explaining GraphQL and How it Differs from REST and SOAP APIs. GraphQL is a newcomer in helping us to build efficient APIs and is becoming a highly viable alternative to REST.
In today’s article, we will focus on starting to put the pieces together with GraphQL, by building a GraphQL API with Node.js.
Before we start, we need to define the functionality of our API. The application will be a simple project talents matcher. It will list all projects and find the best candidates for a given project, based on the tech skills the project requires.
In this tutorial, we will use the hapi
framework with apollo-server for building the GraphQL API.
Check out our repository with finished project.
Creating a new project & setup
Go to your projects directory and create a new project for the app:
mkdir nodejs-graphql && cd $_
Inside the project itself let’s create a new package.json
file which will list our project dependencies:
package.json
{
"name": "nodejs-graphql",
"version": "1.0.0",
"scripts": {
"start": "babel-node index.js"
},
"dependencies": {
"apollo-server-hapi": "^1.1.2",
"babel-cli": "^6.26.0",
"babel-polyfill": "^6.26.0",
"babel-preset-es2015": "^6.24.1",
"graphql": "^0.11.4",
"graphql-tools": "^1.2.3",
"hapi": "^16.6.2"
}
}
To give you more info about the packages we are using that you might not be familiar with:
-
appollo-server-hapi -
Apollo
integration forhapi
framework - graphql-tools - for connecting our schema with custom resolvers (more on this in the schema definition later on)
- babel set of plugins - for processing our JavaScript files
Now we can install the dependencies with npm install
.
Let’s also create .babelrc
to host our Babel configuration:
babel.rc
{
"presets": ["es2015"],
"ignore": [
"node_modules"
]
}
We use the babel-cli
to enable modern JavaScript features in our Node.js code (except Sequelize
definitions - more on that later).
Now let’s create a simple index.js
file which will be the starting point for our app:
index.js
// index.js
import hapi from 'hapi';
import schema from './graphql/schema';
import { graphqlHapi, graphiqlHapi } from 'apollo-server-hapi';
const server = new hapi.Server();
server.connection({
host: 'localhost',
port: '3200'
});
server.register({
register: graphqlHapi,
options: {
path: '/graphql',
graphqlOptions: { schema },
route: {
cors: true
}
}
});
server.register({
register: graphiqlHapi,
options: {
path: '/graphiql',
graphiqlOptions: {
endpointURL: '/graphql'
}
}
});
server.start((err) => {
if (err) {
throw err;
}
console.log(`Server running at: ${server.info.uri}`);
});
This is a very simple hapi
application that runs on localhost:3200
and contains a single API endpoint (/graphql
).
We are also using graphiql (via the graphiqlHapi
extension for hapi
) for a nice browser-based IDE that helps us work with GraphQL APIs.
Let’s also define the simplest possible schema in the graphql/schema.js
file that will allow us to test the app:
// graphql/schema.js
import { makeExecutableSchema } from 'graphql-tools'
const typeDefs = `
type Project {
id: ID!,
name: String
}
type Query {
projects: [Project]
}
`;
export default makeExecutableSchema({ typeDefs });
Running the app
Not we can start the app by running: npm run start
. If you don’t see any errors then visit in your browser:
http://localhost:3200/graphiql
and run a query to list all available types:
{__schema{types{name}}}
Schema definition
Before we start writing queries we need to finish defining our schema. As we mentioned in the introduction, we will have 2 types:
- project (with id, name)
- candidate (with id, fullName)
Let’s update our types in the schema.js
file to reflect this:
// graphql/schema.js
import { makeExecutableSchema } from 'graphql-tools'
const typeDefs = `
type Project {
id: ID!,
name: String
}
type Candidate {
id: ID!,
fullName: String,
}
type Query {
projects: [Project]
}
`;
export default makeExecutableSchema({ typeDefs });
Let’s check if our API works correctly by adding some dummy data.
We will start by creating a resolvers.js
file inside the graphql/
directory with simple, hardcoded data:
// graphql/resolvers.js
const projects = {
1: {
id: 1,
name: 'Project 1'
},
2: {
id: 2,
name: 'Project 2'
}
};
export default {
Query: {
projects() {
return Object.values(projects);
}
}
};
We basically have a projects
collection (filled with sample data) which we export in the projects
function inside the Query
object.
Now we need to inform our schema about our custom resolvers:
// graphql/schema.js
import resolvers from './resolvers';
...
export default makeExecutableSchema({ typeDefs, resolvers });
If we restart the app, go to the http://localhost:3200/graphiql
page and run the following query:
{
projects {
id,
name
}
}
We should see our data:
You can play with the query by modifying the required data and checking results.
Connecting the schema with a database
For purposes of this tutorial, we will use sqlite3
as a database engine to keep things simple.
Before continuing, please ensure you have it installed in your system. If you don’t, below are installation commands for the most popular OSes:
-
OS X:
brew install sqlite
-
Ubuntu:
apt-get install sqlite
We also need to install the sqlite3
package and some helpful libraries
- casual - for generating fake data in our seeds
- lodash - for utility functions
- sequalize - database ORM with CLI for generators
npm install -S casual lodash sequelize sequelize-cli sqlite3
Let’s initialize the Sequelize
config:
sequelize init
And update our config file (config/config.json
):
{
"development": {
"host": "localhost",
"dialect": "sqlite",
"storage": "database.sqlite"
}
}
In production ready projects we would have also our test
and production
environments, but for the purposes of this tutorial just development
will work fine.
Now we can start generating our models:
sequelize model:generate --name Project --attributes name:string
sequelize model:generate --name Candidate --attributes fullName:string
sequelize model:generate --name Skill --attributes name:string
sequelize model:generate --name CandidatesSkill --attributes candidateId:integer,skillId:integer, experience:integer
sequelize model:generate --name ProjectsSkill --attributes projectId:integer,skillId:integer
Inside all files generated by the Sequelize
CLI, we will stick to the syntax used by the generator because ES2015/2017
is not supported yet (it will be introduced in v4) and some files haven’t been ported to the new version yet (i.e. model definitions).
Now we open migrations/xxx-create-candidates-skill.js
and remove createdAt
and updatedAt
attributes as they are not needed for join tables. We also need to make candidateId
and skillId
unique indexes to ensure the same skill cannot be assigned to the same candidate more than once.
After the changes the file should look like this:
// migrations/xxx-create-candidates-skill.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('CandidatesSkills', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
candidateId: {
allowNull: false,
type: Sequelize.INTEGER
},
skillId: {
allowNull: false,
type: Sequelize.INTEGER
},
}, {
uniqueKeys: [{
singleField: false,
fields: ['candidateId', 'skillId']
}]
});
},
down: (queryInterface) => {
return queryInterface.dropTable('CandidatesSkills');
}
};
We then apply similar changes to the ProjectsSkills
join table inside the migrations/xxx-projects-skill.js
file:
// migrations/xxx-create-projects-skill.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('ProjectsSkills', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
projectId: {
allowNull: false,
type: Sequelize.INTEGER
},
skillId: {
allowNull: false,
type: Sequelize.INTEGER
},
}, {
uniqueKeys: [{
singleField: false,
fields: ['projectId', 'skillId']
}]
});
},
down: (queryInterface) => {
return queryInterface.dropTable('ProjectsSkills');
}
};
We also need to let our Projec
ts
Skill
model know that timestamps have been disabled:
// models/projectsskills.js
'use strict';
module.exports = (sequelize, DataTypes) => {
var ProjectsSkill = sequelize.define('ProjectsSkill', {
projectId: DataTypes.INTEGER,
skillId: DataTypes.INTEGER
}, {
timestamps: false
});
return ProjectsSkill;
};
Feel free to check other migrations/*.js
files to familiarize yourself with the migrations API.
If you are coming from frameworks like Ruby on Rails then it should all look pretty familiar to you.
Now we can migrate the database with the sequelize db:migrate
command.
Let’s add some dummy data by creating seeds for our models:
sequelize seed:generate --name skills
sequelize seed:generate --name projects
sequelize seed:generate --name candidates
sequelize seed:generate --name candidates-skills
sequelize seed:generate --name projects-skills
The above commands will create placeholders inside the seeders
directory for each model from our application.
Adding dummy data to the model
Here is the content of each file where we input some dummy data (simplified for the purpose of this tutorial):
Skills
// seeders/**-skills.js
'use strict';
const skillNames = ['Ruby', 'Ruby on Rails', 'Node.js', 'Elixir', 'Phoenix', 'React', 'Vue.js', 'Ember'];
module.exports = {
up: (queryInterface, Sequelize) => {
const now = new Date();
const skills = skillNames.map((name) => {
return {
name,
createdAt: now,
updatedAt: now
};
});
return queryInterface.bulkInsert('Skills', skills, {})
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('skills', null, {});
}
};
Projects
// seeders/**-projects.js
'use strict';
const casual = require('casual');
module.exports = {
up: (queryInterface, Sequelize) => {
const now = new Date();
const projects = Array(5).fill(null).map(() => {
return {
name: casual.title,
createdAt: now,
updatedAt: now
};
});
return queryInterface.bulkInsert('Projects', projects, {});
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('Projects', null, {});
}
};
Candidates
// seeders/**-candidates.js
'use strict';
const casual = require('casual');
module.exports = {
up: (queryinterface, sequelize) => {
const now = new Date();
const candidates = Array(10).fill(null).map(() => {
return {
fullName: casual.full_name,
createdAt: now,
updatedAt: now
};
});
return queryinterface.bulkInsert('Candidates', candidates, {});
},
down: (queryinterface, sequelize) => {
return queryinterface.bulkDelete('Candidates', null, {});
}
};
CandidatesSkills
// seeders/**-candidates-skills.js
'use strict';
const random = require('lodash/random');
const sampleSize = require('lodash/sampleSize');
module.exports = {
up: (queryInterface, Sequelize) => {
let ids = Array(8).fill(null).map((_, i) => i + 1);
const candidatesSkills = ids.reduce((memo, candidateId) => {
const skillIds = sampleSize(ids, 5);
const candidateSkills = skillIds.map((skillId) => {
return { candidateId, skillId, experience: random(0, 5) };
});
return memo.concat(candidateSkills);
}, []);
return queryInterface.bulkInsert('CandidatesSkills', candidatesSkills, {});
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('CandidatesSkills', null, {});
}
};
ProjectsSkills
// seeders/**-projects-skills.js
'use strict';
const sampleSize = require('lodash/sampleSize');
module.exports = {
up: (queryInterface, Sequelize) => {
let ids = Array(8).fill(null).map((_, i) => i + 1);
const projectsSkills = ids.reduce((memo, projectId) => {
const skillIds = sampleSize(ids, 5);
const projectSkills = skillIds.map((skillId) => {
return { projectId, skillId };
});
return memo.concat(projectSkills);
}, []);
return queryInterface.bulkInsert('ProjectsSkills', projectsSkills, {});
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('ProjectsSkills', null, {});
}
};
Now let’s fill our database with our pre-defined data:
sequelize db:seed:all
Let’s modify our resolver.js
file to use our Project
model for fetching all projects from the database.
// graphql/resolvers.js
import Models from '../models';
const { Project } = Models;
export default {
Query: {
projects() {
return Project.findAll();
}
}
};
If you start the app again (npm run start
) and query for projects we will see real data from our database.
Finding the best candidates for the project (SQL)
In the previous step we returned all available projects. Now we will focus on our matching algorithm, that will find the best candidates based on the number of matched skills and years of experience in using those skills.
To find our best candidate we need to follow this procedure:
- find required skills by the project
- find all candidates that have at least one skill that the project requires
- sort candidates based on the number of required skills and sum of experience in those skills
Our SQL query will look more or less like this:
SELECT CandidatesSkills.candidateId,
ProjectsSkills.projectId,
COUNT(*) AS matchedSkillsNo,
SUM(CandidatesSkills.experience) AS experience
FROM CandidatesSkills
INNER JOIN ProjectsSkills
ON CandidatesSkills.skillId = ProjectsSkills.skillId
GROUP BY candidateId, projectId
We will create a database view and Sequelize model that will operate on such a view under the hood. However, from the interface perspective it will look like we are using a regular Sequlize model.
Let’s generate the CandidatesProject
model which will use the SQL view under the hood:
sequelize model:generate --name CandidatesProject --attributes candidateId:integer,projectId:integer,matchedSkillsNo:integer,matchedSkills:string,experience:integer
It will also create a new create-candidates-projects
migration where we will place our view.
We need to use raw SQL to create the view as Sequelize
does not support creating views out of the box:
// migrations/xxx-create-candidates-projects.js
'use strict';
const query = `
CREATE VIEW CandidatesProjects AS
SELECT CandidatesSkills.candidateId,
ProjectsSkills.projectId,
COUNT(*) AS matchedSkillsNo,
GROUP_CONCAT(Skills.name) AS matchedSkills,
SUM(CandidatesSkills.experience) AS experience
FROM CandidatesSkills
INNER JOIN ProjectsSkills
ON CandidatesSkills.skillId = ProjectsSkills.skillId
INNER JOIN Skills
ON CandidatesSkills.skillId = Skills.id
GROUP BY candidateId, projectId
`;
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query(query);
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query('DROP VIEW CandidatesProjects');
}
};
The query contains an additional JOIN
with the Skills
table so we can include skill names that have been matched in the results.
We need to turn timestamps
off in our new CandidatesProject
model so that Sequelize
will not throw the error that it can not find the createdAt
and updatedAt
columns during the join:
// models/candidatesproject.js
'use strict';
module.exports = (sequelize, DataTypes) => {
var CandidatesProject = sequelize.define('CandidatesProject', {
candidateId: DataTypes.INTEGER,
projectId: DataTypes.INTEGER,
skills: DataTypes.INTEGER,
experience: DataTypes.INTEGER
}, {
timestamps: false
});
return CandidatesProject;
};
Now let’s modify our Project
model to have an association with Candidate
(using our CandidatesProjects
view) and Skill
(using ProjectsSkills
table):
models/project.js
// models/project.js
'use strict';
module.exports = (sequelize, DataTypes) => {
var Project = sequelize.define('Project', {
name: DataTypes.STRING
});
Project.associate = function(models) {
var Candidate = models.Candidate;
var CandidatesProject = models.CandidatesProject;
var ProjectsSkill = models.ProjectsSkill;
var Skill = models.Skill;
Project.belongsToMany(Candidate, {
through: {
model: CandidatesProject,
unique: false
},
foreign_key: 'projectId'
});
Project.belongsToMany(Skill, {
through: {
model: ProjectsSkill,
unique: false
},
foreign_key: 'projectId'
});
};
return Project;
};
By having a relation between Project
and Skill
we are able to include a list of skills that the project requires from the candidates.
Now we need to modify our schema
:
// graphql/schema.js
type Project {
id: ID!,
name: String,
skills: String
}
type Candidate {
id: ID!,
fullName: String,
}
type Query {
projects: [Project],
candidates(projectId: Int): [Candidate]
}
We also need to update our resolvers so that skills
are available inside the projects
query results. We also have created an additional resolver (for the candidates
query) which will use our previously defined CandidatesProject
model to fetch the best candidates using our custom SQL view).
// graphql/resolvers.js
import Models from '../models';
const { Project, Skill, sequelize } = Models;
const buildProjects = (projects) => {
return projects.map((project) => {
const skillNames = project.Skills.map((s) => s.name);
return Object.assign(project.get(), {
skills: skillNames.join(', ')
});
});
};
export default {
Query: {
projects() {
return Project.findAll({
include: [{
model: Skill
}]
}).then(buildProjects);
},
candidates(_, { projectId }) {
return Project.findOne({ where: { id: projectId } })
.then((project) => {
if(!project) { return []; }
return project.getCandidates({
order: [
sequelize.literal('CandidatesProject.matchedSkillsNo DESC'),
sequelize.literal('CandidatesProject.experience DESC')
]
});
});
}
}
};
Running the query
Now if we send our query to the GraphQL endpoint:
{
candidates(projectId: 1) {
id,
fullName
}
}
We should then see the names of top matching candidates based on the number of matched skills and years of experience in GraphiQL:
Sending a query to list the projects would return their required skills as well:
Adding skills and experience to candidates data
Let’s add info about the matched skills and experience for each candidate so that it’s available to the client.
First we need to update our schema to include the additional attributes (which are available in our SQL view):
// graphql/schema.js
...
type Candidate {
id: ID!,
fullName: String,
experience: Int,
matchedSkillsNo: Int,
matchedSkills: String
}
...
Now, let’s modify our resolver to include the experience
and skills
from the CandidatesProject
model using a custom buildCandidates
function to build desired result structure by combining the results from the Candidate
and CandidateProject
tables together.
// graphql/resolvers.js
import { Project } from '../models';
const { Project, Skill, sequelize } = Models;
const buildCandidates = (candidates) => {
return candidates.map((candidate) => {
const matchedSkills = candidate.CandidatesProject.matchedSkills;
return Object.assign(candidate.get(), {
experience: candidate.CandidatesProject.experience,
matchedSkillsNo: candidate.CandidatesProject.matchedSkillsNo,
matchedSkills: matchedSkills.split(',').join(', ')
});
});
};
...
export default {
Query: {
projects() {
return Project.findAll({
include: [{
model: Skill
}]
}).then(buildProjects);
},
candidates(_, { projectId }) {
return Project.findOne({ where: { id: projectId } })
.then((project) => {
if(!project) { return []; }
return project.getCandidates({
order: [
sequelize.literal('CandidatesProject.matchedSkillsNo DESC'),
sequelize.literal('CandidatesProject.experience DESC')
]
}).then(buildCandidates);
});
}
}
}
Now skills and experience for each candidate is at our disposal:
{
candidates(projectId: 3) {
id,
fullName,
matchedSkillsNo,
matchedSkills,
experience
}
}
At iRonin, we love to leverage the power of GraphQL both internally and in our client’s projects where relevant. We are adept at creating complex APIs, both with GraphQL, as well as by using RESTful architectures. If your business is interested in using GraphQL or needs a talent injection with Node.js developers, then make sure to send us through an email or hit us back in the comments section - we would love to hear from you.

Experts in software development
We are a 100% remote team of software development experts, providing web & mobile application development and DevOps services for international clients.

