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.
For more knowledge, see our whole GraphQL Development Series:
- GraphQL Part #1 - Explaining GraphQL and How it Differs from REST and SOAP APIs
- GraphQL Part #2 - Creating an API with Node.js Using GraphQL [you are reading this article now]
- GraphQL Part #3 - Creating a GraphQL Client with the Vue.js Framework
- GraphQL Part #4 - Build GraphQL API with Ruby on Rails
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 for `hapi` 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
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.