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:

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 ProjectsSkill 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.