See all articles

GraphQL Part 4 - API with Ruby on Rails

iRonin IT Team

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.

Setting up our data

Let’s start with a blank `api` project:

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

1 2 3 4 5 6 7 8 9 10 11 $ 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 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):

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 # 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:

1 2 3 4 5 6 $ 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:

1 2 3 Dir[Rails.root.join('db', 'seeds', '**', '*.rb')].sort.each do |f| require f end

We are doing `sort` to ensure the files are included alphabetically, as they are order dependent.

Before we proceed, let’s add ffaker gem to the `Gemfile` to help us with generating some data in the seed files.

Now we can write our dummy data:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # 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 setup our database (create it, migrate it and seed it with pre-defined data):

1 2 3 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:

Let’s add all those libraries to our Gemfile and run `bundle install`. After that we can setup our GraphQL API:

1 bundle exec rails generate graphql:install

We need to modify our `routes.rb` file to mount GraphiQL:

1 2 3 4 5 6 7 8 # 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:

1 2 3 4 # config/application.rb ... require "sprockets/railtie" if Rails.env.development? ...

Now we ready to starting writing our first query - getting all projects.

Projects query

We will start with the Project type definition for our app:

1 2 3 4 5 6 7 8 # 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:

1 2 3 4 5 6 7 # 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:

1 2 3 4 # 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:

1 2 3 4 5 6 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:

1 2 3 4 5 6 7 8 # 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:

1 2 3 4 5 6 7 8 9 10 11 # 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 for loading 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):

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 # 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. By 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:

1 2 3 4 5 6 7 # 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 required skills:

1 2 3 4 5 6 7 8 9 10 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 sum of experience in those skills.

We will start with defining our candidate type:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 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):

1 2 3 4 5 6 7 8 9 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:

1 2 3 4 5 6 7 8 9 # 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:

1 2 3 4 5 6 7 8 9 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 here.

If you’d like help with your Ruby on Rails projects then speak to the experts at iRonin. Our senior team can accelerate your development, adding the functionality you need with the scale you desire. Email us to find out more!

Similar articles

Previous article

How To