It’s time to get started with Part 4 of our GraphQL series. This time, we will build an API with Ruby on Rails. This will provide the same features and functionality as an API built with Node.js, as we covered in Part 2.
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
- GraphQL Part #3 - Creating a GraphQL Client with the Vue.js Framework
- GraphQL Part #4 - Build GraphQL API with Ruby on Rails [you are reading this article now]
Setting Up Our Data
Let’s start with a blank `api` project:
rails new -d sqlite3 --api talent-macher-api
Then we’ll add some initial models and dummy data. We will create the same models and relationships we had in our Node.js version for continuity.
$ bundle rails g model Candidate fullname
...
$ bundle rails g model Project name
...
$ bundle exec rails g model Skill name experience:integer
...
$ bundle exec rails g model CandidatesSkill candidate:references skill:references
...
$ bundle exec rails g model ProjectsSkill project:references skill:references
...
$ bundle exec rails g migration create_candidates_projects
We should also add unique indexes to the `candidates_skills` and `projects_skills` tables to ensure we won’t assign the same skill to a project or candidate more than once:
# db/migrate/2018...create_dandidates_skills.rb
class CreateCandidatesSkills < ActiveRecord::Migration[5.1]
def change
...
add_index :candidates_skills, %i[candidate_id skill_id], unique: true
end
end
# db/migrate/2018...create_projects_skills.rb
class CreateProjectsSkills < ActiveRecord::Migration[5.1]
def change
...
add_index :projects_skills, %i[project_id skill_id], unique: true
end
end
We also need to create a view to fetch the best candidates based on the project’s skill requirements (we will use the same code as in part 2):
# db/migrate/2018...create_candidates_projects.rb
class CreateCandidatesProjects < ActiveRecord::Migration[5.1]
def up
execute <<-SQL
CREATE VIEW candidates_projects AS
SELECT candidates_skills.candidate_id,
projects_skills.project_id,
COUNT(*) AS matched_skills_no,
GROUP_CONCAT(skills.name) AS matched_skills,
SUM(candidates_skills.experience) AS experience
FROM candidates_skills
INNER JOIN projects_skills
ON candidates_skills.skill_id = projects_skills.skill_id
INNER JOIN skills
ON candidates_skills.skill_id = skills.id
GROUP BY candidate_id, project_id
SQL
end
def down
execute 'DROP view candidates_projects;'
end
end
Now, let’s open each of those models and create the associations between them:
# app/models/candidate.rb
class Candidate < ApplicationRecord
has_many :candidates_skills, inverse_of: :candidate, dependent: :destroy
has_many :skills, through: :candidates_skills
has_many :candidates_projects, inverse_of: :candidate, dependent: :destroy
has_many :projects, through: :candidates_projects
end
# app/models/candidates_project.rb
class CandidatesProject < ApplicationRecord
belongs_to :candidate, inverse_of: :candidates_projects
belongs_to :project, inverse_of: :candidates_projects
end
# app/models/candidates_skill.rb
class CandidatesSkill < ApplicationRecord
belongs_to :candidate, inverse_of: :candidates_skills
belongs_to :skill, inverse_of: :candidates_skills
end
# app/models/project.rb
class Project < ApplicationRecord
has_many :projects_skills, inverse_of: :project, dependent: :destroy
has_many :skills, through: :projects_skills
end
# app/models/projects_skill.rb
class ProjectsSkill < ApplicationRecord
belongs_to :project, inverse_of: :projects_skills
belongs_to :skill, inverse_of: :projects_skills
end
# app/models/skill.rb
class Skill < ApplicationRecord
has_many :candidates_skills, inverse_of: :skill, dependent: :destroy
has_many :candidates, through: :candidates_skills
has_many :projects_skills, inverse_of: :skill, dependent: :destroy
has_many :projects, through: :projects_skills
end
So far we have declared our models with all the necessary relationships. Now we can create some seeds so that we will be able to test our data to ensure our database setup is correctly configured.
Since we will have seeds for multiple models, let’s store them in separate files:
$ tree db/seeds/
db/seeds/
├── 01_skills.rb
├── 02_projects.rb
└── 03_candidates.rb
0 directories, 3 files
Then we can simply include those files in the `db/seeds.rb` file:
Dir[Rails.root.join('db', 'seeds', '**', '*.rb')].sort.each do |f|
require f
end
We are doing a `sort` to ensure the files are included alphabetically, as they are order-dependent.
Before we proceed, let’s add the ffaker gem to the `Gemfile` to help us with generating some data in the seed files.
Now we can write our dummy data:
# db/seeds/01_skills.rb
skill_names = ['Ruby', 'Ruby on Rails', 'Node.js',
'Elixir', 'Phoenix', 'React', 'Vue.js', 'Ember']
skill_names.each do |skill_name|
Skill.find_or_create_by(name: skill_name)
end
# db/seeds/02_projects.rb
skills = Skill.all
5.times do
project = Project.create(name: FFaker::Product.product)
skills.sample(5).each do |skill|
project.skills << skill
end
end
# db/seeds/03_candidates.rb
skills = Skill.all
10.times do
candidate = Candidate.create(fullname: FFaker::Name.name)
skills.sample(5).each do |skill|
candidate.candidates_skills.create(skill: skill, experience: rand(5))
end
end
Finally, we are ready to set up our database (create it, migrate it, and seed it with pre-defined data):
bundle exec rake db:create
bundle exec rake db:migrate
bundle exec rake db:seed
Setup a GraphQL API Endpoint
Since our models are ready, we can focus on building a GraphQL endpoint.
We will use a couple of additional gems:
- graphql-ruby - main library for working with GraphQL in Ruby apps
- graphql-activerecord - for easy mapping between GraphQL types and ActiveRecord models
- graphql-batch - for fetching records in batches (prevent N+1 queries)
- graphiql-rails - GraphQL editor for easier development
Let’s add all those libraries to our Gemfile and run `bundle install`. After that we can set up our GraphQL API:
bundle exec rails generate graphql:install
We need to modify our `routes.rb` file to mount GraphiQL:
# config/routes.rb
Rails.application.routes.draw do
post '/graphql', to: 'graphql#execute'
if Rails.env.development?
mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: '/graphql'
root to: redirect('/graphiql')
end
end
Since our Rails app is in the API mode, we need to uncomment `require "sprockets/railtie"` in the `config/application.rb` so GraphiQL can correctly load for us:
# config/application.rb
...
require "sprockets/railtie" if Rails.env.development?
...
Now, we are ready to start writing our first query - getting all projects.
Projects Query
We will start with the Project type definition for our app:
# app/graphql/types/project_type.rb
Types::ProjectType = GraphQL::ObjectType.define do
name 'Project'
backed_by_model :project do
attr :id
attr :name
end
end
We are using the `backed_by_model` function from `graphql-activerecord` for easy mapping between the ActiveRecord model’s attributes and GraphQL fields.
We need to add our `projects` fields to the `query` type:
# app/graphql/types/query_type.rb
Types::QueryType = GraphQL::ObjectType.define do
name 'Query'
field :projects, !types[Types::ProjectType], resolve: ->(_obj, _args, ctx) {
Project.all
}
end
And then include `query` in our main schema:
# app/graphql/talent_matcher_api_schema.rb
TalentMacherApiSchema = GraphQL::Schema.define do
query(Types::QueryType)
end
After that, we are ready to test our query using the built in Graphiql extension. Let’s start the server (`rails server`) and open `http://localhost:3000/graphiql` to run the query:
query {
projects {
id,
name
}
}
Projects With Skills
Let’s modify our `projects` query so it supports returning projects with skills.
We will start with defining the type for our `Skill` model:
# app/graphq/types/skill_type.rb
Types::SkillType = GraphQL::ObjectType.define do
name 'Skill'
backed_by_model :skill do
attr :id
attr :name
end
end
We are using `backed_by_model` once again.
Now we need to modify our `Types::ProjectType` class to include the `skills` field:
# app/graphql/types/project_type.rb
Types::ProjectType = GraphQL::ObjectType.define do
name 'Project'
backed_by_model :project do
attr :id
attr :name
end
field :skills, types[Types::SkillType], resolve: (proc do |obj|
AssociationLoader.for(Project, :skills).load(obj)
end)
end
We are using the `AssociationLoader` class to load the project’s skills. By using this helper class to load the model’s association we prevent the N+1 queries.
Here is the code for the `AssociationLoader` class (code is mostly taken from the graphql-batch example):
# app/graphql/association_loader.rb
class AssociationLoader < GraphQL::Batch::Loader
def initialize(model, association_name)
@model = model
@association_name = association_name
end
def load(record)
raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
return Promise.resolve(read_association(record)) if association_loaded?(record)
super
end
def cache_key(record)
record.object_id
end
def perform(records)
preload_association(records)
records.each { |record| fulfill(record, read_association(record)) }
end
def preload_association(records)
::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
end
def read_association(record)
record.public_send(@association_name)
end
def association_loaded?(record)
record.association(@association_name).loaded?
end
end
It basically preloads associations (if they are not yet preloaded) for a group of records (batch) using `ActiveRecord::Associations::Preloader` class. Using `object_id` as a `cache_key`, we ensure that associations are loaded for all records (even multiple instances of a record with the same ID).
Because we are returning Promise for the `skills` field, we also need to modify our main schema to support it:
# app/graphql/talent_matcher_api_schema.rb
TalentMacherApiSchema = GraphQL::Schema.define do
# Set up the graphql-batch gem
lazy_resolve(Promise, :sync)
use GraphQL::Batch
query(Types::QueryType)
end
With all the code from above, we should be able to get projects with the required skills:
query {
projects {
id,
name,
skills {
id,
name
}
}
}
Project candidates
Now we can work on the `candidates` query to find the best candidates for a project based on the number of matched skills and the sum of experience in those skills.
We will start with defining our candidate type:
# app/graphql/types/candidate_type.rb
Types::CandidateType = GraphQL::ObjectType.define do
name 'Candidate'
field :id, !types.ID, property: :candidate_id
field :fullname, types.String do
resolve(proc do |obj|
RecordLoader.for(Candidate).load(obj.candidate_id)
.then(&::fullname)
end)
end
field :matchedSkills, types[types.String] do
resolve(proc { |obj| obj.matched_skills.split(',') })
end
field :matchedSkillsNo, types.Int, property: :matched_skills_no
field :experience, types.Int
end
Since the candidate that we sent to the client is represented by 2 database models (`Candidate` and `CandidatesProject`) we have to manually define field resolvers (`backed_by_model` won’t work in this case).
We are also using the `RecordLoader` class for preventing n+1 queries when loading associated the records (for one-to-many relationships). Here is how this class might look like (it’s also taken from the graphql-batch example):
class RecordLoader < GraphQL::Batch::Loader
def initialize(model)
@model = model
end
def perform(ids)
@model.where(id: ids).each { |record| fulfill(record.id, record) }
ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
end
end
We also included `skills` association for `candidates`. Now we can modify our query:
# app/graphql/types/query_type.rb
Types::QueryType = GraphQL::ObjectType.define do
name 'Query'
field :candidates, types[Types::CandidateType], resolve: ->(_obj, args, _ctx) {
project = Project.find(args['projectId'])
project.candidates_project
}
...
end
Let’s run our query to fetch best the candidates for the project:
query {
candidates(projectId: 1) {
id,
fullname,
matchedSkills,
matchedSkillsNo,
experience
}
}
That’s all for today. We hope you enjoyed seeing it all in action! Full code for the tutorial can be found iRonin’s GitHub repository.
If you’d like to get guidance or consultation from a top Ruby on Rails development company, then speak to the experts at iRonin. Our senior team can accelerate your development, adding the functionality you need with the scale you desire. Contact us to find out more!