JSON:API On Rails (Part 1 of 2)
August 5, 2019
As promised, this week we are going to build a JSON API with Ruby on Rails. Before we get started though let’s make sure everyone is setup and knows what we are building. First the tech requirements:
- Ruby 2.6.3
- Rails 6.0.0.rc2
- PostgreSQL
- Insomnia (for local API requests on OSX)
What We Are Building
We are going to build a light rewrite of the Basecamp 3 API in our lovely new JSON:API format. For the sake of time and simplicity we will be skipping the authentication workflow and instead will dive right into creating our data graph.
We will be referencing the guides and docs from Graphiti to build our project. In addition we are going to try to build this thing following BDD principles with RSpec.
Create The New Application
To create a new Rails API we are going to use the Graphiti template to generate the new project. We are going to call this app “Less Projects”. In the command below you will notice the -T
flag which tells Rails to skip Test::Unit. Since we are using RSpec there is no need for the second testing library.
# Generate the project
rails new less_projects -d postgresql -T --api -m https://www.graphiti.dev/template
# Press enter when prompted to change Graphiti's defaults
# This will generate our routes under `/api/v1`
# Switch to the app directory
cd less_projects
# create the database
rails db:create
Projects
Projects will serve as our top level object. They are a container for all other resources.
Creating Projects
Graphiti and Rails provides us with some wonderful “generators” to get started.
# first generate the model
rails g model project name:string description:text purpose:string status:string
# migrate the database (this adds the projects table with our fields to the DB)
rails db:migrate
# Finally, generate our first Graphiti resource
rails g graphiti:resource Project name:string description:string purpose:string status:string
Let’s step through these generated files quick. Graphiti just handled a lot of boilerplate for us. If you look at the ProjectsController (app/controllers/projects_controller.rb
) you will see that all 5 of the basic CRUD actions are covered. You might also notice references to ProjectResource
. This might be a little unfamiliar. It’s a construct that Graphiti introduces to perform most of the API behaviors. It handles fetching, mutating, filtering, pagination, and all kinds of useful things.
class ProjectResource < ApplicationResource
attribute :name, :string
attribute :description, :string
attribute :purpose, :string
attribute :status, :string
end
Next let’s take a look at the ProjectResource. Again these can do a lot of things, but let’s start with our bare bones. One of the key things a resource does is that it serializes (transforms) the Project model instances into JSON:API compliant JSON objects. In order to do that you must define what fields or “attributes” you want serialized. For our purposes these defaults (above) will work just fine.
Before we move into the tests let’s put them in a more typical hierarchy. RSpec has a fairly default folder hierarchy and moving our project into that will match more of what you will see out there in the wild. In this case it clearly marks our api specs as “request specs”.
mkdir spec/requests
mv spec/api/ spec/requests/api
Listing Projects
The test we will start with is listing projects. We can see that Graphiti was very helpful and wrote our first test for us. That’s pretty sweet! There are a few small things hidden from us. The first is jsonapi_get
. This method is included by the Graphiti spec helper gem. It abstracts away some of the implementation details of the JSON:API spec, chiefly headers, to remove some test boilerplate and make tests easier to write.
require 'rails_helper'
RSpec.describe "projects#index", type: :request do
let(:params) { {} }
subject(:make_request) do
jsonapi_get "/api/v1/projects", params: params
end
describe 'basic fetch' do
let!(:project1) { create(:project) }
let!(:project2) { create(:project) }
it 'works' do
expect(ProjectResource).to receive(:all).and_call_original
make_request
expect(response.status).to eq(200), response.body
expect(d.map(&:jsonapi_type).uniq).to match_array(['projects'])
expect(d.map(&:id)).to match_array([project1.id, project2.id])
end
end
end
The other thing it abstracts is the parsed response from the request. It’s hidden away in the d
variable. This isn’t exactly named well so let’s use a different method that Graphiti supports and reads a little easier: “jsonapi_data”.
# spec/requests/api/v1/projects/index_spec.rb
# old code
# expect(d.map(&:jsonapi_type).uniq).to match_array(['projects'])
# expect(d.map(&:id)).to match_array([project1.id, project2.id])
# new code
expect(jsonapi_data.map(&:jsonapi_type).uniq).to match_array(['projects'])
expect(jsonapi_data.map(&:id)).to match_array([project1.id, project2.id])
Alright, now let’s run our test and see if things work.
rspec spec/requests/api/v1/projects/index_spec.rb
# output
Finished in 0.22877 seconds (files took 2.76 seconds to load)
2 examples, 0 failures
Woot! Our test already passes. Let’s take a moment to boot up the Insomnia app and take a look at our payloads. All we need to do is a little bit of setup first and start our server.
First let’s modify our default factory so that we can identify projects a little easier.
# spec/factories/projects.rb
FactoryBot.define do
factory :project do
sequence(:name) { |n| "Project #{n}" }
description { "Something cool" }
purpose { "To organize the project" }
status { "active" }
end
end
Next we setup some data for our API.
# boot up rails console
rails console
# once in console, seed a few projects
FactoryBot.create_list(:project, 3)
# exit out of console
exit
# and finally start our server
rails s
The server should now be running at http://localhost:3000. We can at last jump into Insomnia and see what our API returns for our first endpoint.
Step 1: Create a new Request
Step 2: Setup the default headers and Url
JSON:API Spec and Graphiti requires us to specify the format of the data for the server (Content-Type) and the format of data we expect to be returned (Accept). Both values will be application/vnd.api+json
.
Step 3: Click “Send” and win!
Here is our data. You will notice there are 2 top level fields: “data” and “meta”. Data is where all of our primary data will be contained. The “meta” object is for more miscallenous types of data such as counts of records, pagination, etc. You might also notice that each “attributes” object has all of the fields we specified in our ProjectResource
class. Not to shabby.
Fetching Single Projects
The next thing we want to make sure our clients can do is look up single projects. Again, like good devs let’s start with the test.
# spec/requests/api/v1/projects/show_spec.rb
require 'rails_helper'
RSpec.describe "projects#show", type: :request do
let(:params) { {} }
subject(:make_request) do
jsonapi_get "/api/v1/projects/#{project.id}", params: params
end
describe 'basic fetch' do
let!(:project) { create(:project) }
it 'works' do
expect(ProjectResource).to receive(:find).and_call_original
make_request
expect(response.status).to eq(200)
expect(jsonapi_data.jsonapi_type).to eq('projects')
expect(jsonapi_data.id).to eq(project.id)
end
end
end
And now we run the test.
rspec spec/requests/api/v1/projects/show_spec.rb
# output
Finished in 0.1661 seconds (files took 2 seconds to load)
2 examples, 0 failures
Just like before we find out our work was done for us and the tests pass. And just like before let’s go into Insomnia and add our request.
Pro Tip: Right click the previous request and click duplicate to start with a request with the required headers.
Creating Projects
Now that we have fetching of projects in place, let’s learn how to create new ones in our API. Like before, we will start with the test.
# spec/requests/v1/api/projects/create_spec.rb
require 'rails_helper'
RSpec.describe "projects#create", type: :request do
subject(:make_request) do
jsonapi_post "/api/v1/projects", payload
end
describe 'basic create' do
let(:params) { attributes_for(:project) }
let(:payload) do
{
data: {
type: 'projects',
attributes: params
}
}
end
it 'works' do
expect(ProjectResource).to receive(:build).and_call_original
expect { make_request }.to change { Project.count }.by(1)
expect(response.status).to eq(201)
end
end
end
The are a few key things to point out here:
- All payloads start with a
data
hash/object type
is required (this is the pluralized name of the object)attributes
is where all the non-relationship fields are contained
If you are not familiar with attributes_for
don’t worry. This is just a convenient way to extract random attributes for a given factory. Let’s check this out in Insomnia so we can see a real world example.
Step 1: Create the request
Again we need to duplicate and rename the previous request. Also, let’s make sure to switch from a GET request to a POST request. When creating new resources over the API, the POST method is what the JSON:API spec requires.
Step 2: Create the POST body and Change the URL
We should make sure to change the url to reference /api/v1/projects
. Think of this as the “root” url for projects. Making a GET request to this endpoint fetches projects and making a POST request creates them. Also make sure to change the request content to “JSON”.
You will also notice that much like our responses from the previous requests we are making use of the data
field as the root element and passing our desired traits through the attributes
hash. This is a pattern we will continue to re-use.
Step 3: Click “Send” and win!
Boom, just like that we have our new project. But what if we add some validations to projects and our request is invalid? Great question! Let’s make some quick alterations and see what happens. We start by writing a failing test.
# spec/models/project_spec.rb
require 'rails_helper'
RSpec.describe Project, type: :model do
describe 'validations' do
let(:project) { described_class.new }
it 'requires a name' do
expect(project).not_to be_valid
expect(project.errors["name"]).to include("can't be blank")
end
end
end
And then we write the code to make the tests pass.
# app/models/project.rb
class Project < ApplicationRecord
validates :name, presence: true
end
If we did everything properly then our tests should pass.
rspec spec/models/project_spec.rb
# output
Finished in 1.03 seconds (files took 0.84581 seconds to load)
2 examples, 0 failure
B E A utiful! Our test now passes. Let’s try to create a new project except we won’t give it a name. Since we just added the feature we know this should fail.
Here is what we we see if we try this in Insomnia:
From a client standpoint this is pretty nice. We are given a response that tells us what attribute are invalid and why.
Updating a Project
Now that we can create projects, let’s learn how to update existing ones in our API. Like before, we will start with the test.
# spec/requests/v1/api/projects/update_spec.rb
require 'rails_helper'
RSpec.describe "projects#update", type: :request do
subject(:make_request) do
jsonapi_put "/api/v1/projects/#{project.id}", payload
end
describe 'basic update' do
let!(:project) { create(:project) }
let(:payload) do
{
data: {
id: project.id.to_s,
type: 'projects',
attributes: {
name: "new name"
}
}
}
end
it 'updates the resource' do
expect(ProjectResource).to receive(:find).and_call_original
make_request
expect(response.status).to eq(200), response.body
expect(project.reload.name).to eq("new name")
end
end
end
Just like when we create a project, we pass our json with “data” as the top level attribute. Let’s check this out in Insomnia so we can see a real world example.
This time we will combine all the steps into 1.
And that’s all it takes to update the project.
Destroying a Project
The last operation that we need to support is destroying a project. Just like in all the other examples…we will start with a test.
# spec/requests/v1/api/projects/destroy_spec.rb
require 'rails_helper'
RSpec.describe "projects#destroy", type: :request do
subject(:make_request) do
jsonapi_delete "/api/v1/projects/#{project.id}", {}
end
describe 'basic destroy' do
let!(:project) { create(:project) }
it 'updates the resource' do
expect(ProjectResource).to receive(:find).and_call_original
make_request
expect(response.status).to eq(200)
expect { project.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
If we run our tests, everything passes. That’s great, but what kind of response do we get from this request? Let’s dive back into Insomnia and add another request; just like we have previously.
The most notable thing in this request is that our response is quite different from the other ones. In this case only meta data is returned.
These 5 actions: list, fetch, update, create, and destroy are the main operations we will perform on each resource. Now let’s move onto some nested resources and see how Graphiti handles them.
TodoLists and TodoItems
Each project can have one or many todo lists. Each of these lists have todo items. Let’s use our generators again to create these resources.
# setup todo_lists
rails g model todo_list project:references name:string
rails db:migrate
rails g graphiti:resource todo_list name:string
# setup todo_list_items
rails g model todo_list_item todo_list:references content:string complete:boolean
rails db:migrate
rails g graphiti:resource todo_list_item content:string complete:boolean
And just like before let’s move our tests under spec/requests
.
mv spec/api/v1/todo_lists spec/requests/api/v1
mv spec/api/v1/todo_list_items spec/requests/api/v1
Now let’s alter our models and add the various relationships and default validations.
# app/models/project.rb
class Project < ApplicationRecord
has_many :todo_lists
has_many :todo_list_items, through: :todo_lists
validates :name, presence: true
end
# app/models/todo_list.rb
class TodoList < ApplicationRecord
belongs_to :project
has_many :todo_list_items
validates :name, presence: true
end
# app/models/todo_list_item.rb
class TodoListItem < ApplicationRecord
belongs_to :todo_list
validates :content, presence: true
end
Next up, let’s alter our resources to add those same relations.
# app/resources/project_resource.rb
class ProjectResource < ApplicationResource
has_many :todo_lists
has_many :todo_list_items
attribute :name, :string
attribute :description, :string
attribute :purpose, :string
attribute :status, :string
end
# app/resources/todo_list_resource.rb
class TodoListResource < ApplicationResource
belongs_to :project
has_many :todo_list_items
attribute :name, :string
end
# app/resources/todo_list_item_resource.rb
class TodoListItemResource < ApplicationResource
belongs_to :todo_list
attribute :content, :string
attribute :complete, :boolean
end
Let’s also make some changes to the new factories so that our tests are a bit more stable and the data a bit more random.
# spec/factories/todo_list_items.rb
FactoryBot.define do
factory :todo_list_item do
todo_list
content { Faker::Lorem.words(number: 5).join('') }
complete { false }
end
end
# spec/factories/todo_lists.rb
FactoryBot.define do
factory :todo_list do
project
sequence(:name) { |n| "#{Faker::Beer.name} #{n}" }
end
end
Alright, now that we have all of the foundations in place, let’s move into our new TodoList
resource.
Listing TodoLists
In order to give Insomnia some data to display, seed some todo lists.
rails c
# in console
# seeds 3 todo lists for the last project we created
FactoryBot.create_list(:todo_list, 3, project: Project.last)
Then create and execute the new request in Insomnia
And of course we need to check our test.
spec/requests/api/v1/todo_lists/index_spec.rb
require 'rails_helper'
RSpec.describe "todo_lists#index", type: :request do
let(:params) { {} }
subject(:make_request) do
jsonapi_get "/api/v1/todo_lists", params: params
end
describe 'basic fetch' do
let!(:todo_list1) { create(:todo_list) }
let!(:todo_list2) { create(:todo_list) }
it 'works' do
expect(TodoListResource).to receive(:all).and_call_original
make_request
expect(response.status).to eq(200)
expect(d.map(&:jsonapi_type).uniq).to match_array(['todo_lists'])
expect(d.map(&:id)).to match_array([todo_list1.id, todo_list2.id])
end
end
end
If we run our test provided by Graphiti we see green!
Finished in 0.2598 seconds (files took 1.5 seconds to load)
2 examples, 0 failures
Fetching TodoLists
Like before, let’s create and execute the a new request in Insomnia to fetch a single TodoList
.
And of course we need to check our test.
spec/requests/api/v1/todo_lists/show_spec.rb
require 'rails_helper'
RSpec.describe "todo_lists#show", type: :request do
let(:params) { {} }
subject(:make_request) do
jsonapi_get "/api/v1/todo_lists/#{todo_list.id}", params: params
end
describe 'basic fetch' do
let!(:todo_list) { create(:todo_list) }
it 'works' do
expect(TodoListResource).to receive(:find).and_call_original
make_request
expect(response.status).to eq(200)
expect(d.jsonapi_type).to eq('todo_lists')
expect(d.id).to eq(todo_list.id)
end
end
end
If we run our test provided by Graphiti we see green!
Finished in 0.19348 seconds (files took 0.32548 seconds to load)
2 examples, 0 failures
Creating TodoList
Let’s create and execute the a new request in Insomnia to create a single TodoList
.
And of course we need to write/fix our test that was autogenerated by Graphiti.
spec/requests/api/v1/todo_lists/create_spec.rb
require 'rails_helper'
RSpec.describe "todo_lists#create", type: :request do
let!(:project) { create(:project) }
subject(:make_request) do
jsonapi_post "/api/v1/todo_lists", payload
end
describe 'basic create' do
let(:params) { attributes_for(:todo_list) }
let(:payload) do
{
data: {
type: 'todo_lists',
attributes: params,
relationships: {
project: {
data: {
type: 'projects',
id: project.id
}
}
}
}
}
end
it 'works' do
expect(TodoListResource).to receive(:build).and_call_original
expect { make_request }.to change { TodoList.count }.by(1)
expect(response.status).to eq(201)
end
end
end
If we run our test provided we see green!
Finished in 0.23687 seconds (files took 0.20839 seconds to load)
2 examples, 0 failures
Updating TodoLists
Let’s create and execute the a new request in Insomnia to update a TodoList
.
And of course we need to write/fix our test that was autogenerated by Graphiti.
spec/requests/api/v1/todo_lists/update_spec.rb
require 'rails_helper'
RSpec.describe "todo_lists#update", type: :request do
subject(:make_request) do
jsonapi_put "/api/v1/todo_lists/#{todo_list.id}", payload
end
describe 'basic update' do
let!(:todo_list) { create(:todo_list) }
let(:payload) do
{
data: {
id: todo_list.id.to_s,
type: 'todo_lists',
attributes: {
name: "new name"
}
}
}
end
it 'updates the resource' do
expect(TodoListResource).to receive(:find).and_call_original
make_request
expect(response.status).to eq(200)
expect(todo_list.reload.name).to eq("new name")
end
end
end
If we run our test provided we see green!
Finished in 0.21061 seconds (files took 0.22622 seconds to load)
2 examples, 0 failures
Destroying TodoLists
Let’s create and execute the a new request in Insomnia to destroy a TodoList
.
And of course we need to write/fix our test that was autogenerated by Graphiti.
spec/requests/api/v1/todo_lists/destroy_spec.rb
require 'rails_helper'
RSpec.describe "todo_lists#destroy", type: :request do
subject(:make_request) do
jsonapi_delete "/api/v1/todo_lists/#{todo_list.id}", {}
end
describe 'basic destroy' do
let!(:todo_list) { create(:todo_list) }
it 'updates the resource' do
expect(TodoListResource).to receive(:find).and_call_original
make_request
expect(response.status).to eq(200)
expect { todo_list.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(json).to eq('meta' => {})
end
end
end
If we run our test provided we see green!
Finished in 0.22919 seconds (files took 0.20982 seconds to load)
2 examples, 0 failures
Whew that’s two of the three resources covered. Time to rip through the TodoListItem
and tease some of the content from part 2 of this article.
Listing TodoListItems
In order to give Insomnia some data to display, seed some todo list items.
rails c
# in console
FactoryBot.create_list(:todo_list_item, 5, todo_list: TodoList.last)
Then create and execute the new request in Insomnia
And of course we need to check our test.
spec/requests/api/v1/todo_list_items/index_spec.rb
require 'rails_helper'
RSpec.describe "todo_list_items#index", type: :request do
let(:params) { {} }
subject(:make_request) do
jsonapi_get "/api/v1/todo_list_items", params: params
end
describe 'basic fetch' do
let!(:todo_list_item1) { create(:todo_list_item) }
let!(:todo_list_item2) { create(:todo_list_item) }
it 'works' do
expect(TodoListItemResource).to receive(:all).and_call_original
make_request
expect(response.status).to eq(200), response.body
expect(d.map(&:jsonapi_type).uniq).to match_array(['todo_list_items'])
expect(d.map(&:id)).to match_array([todo_list_item1.id, todo_list_item2.id])
end
end
end
If we run our test provided by Graphiti we see green!
rspec spec/requests/api/v1/todo_list_items/index_spec.rb
Finished in 0.27564 seconds (files took 0.2522 seconds to load)
2 examples, 0 failures
Fetching TodoListItems
Like before, let’s create and execute the a new request in Insomnia to fetch a single TodoListItem
.
And of course we need to check our test.
spec/requests/api/v1/todo_list_items/show_spec.rb
require 'rails_helper'
RSpec.describe "todo_list_items#show", type: :request do
let(:params) { {} }
subject(:make_request) do
jsonapi_get "/api/v1/todo_list_items/#{todo_list_item.id}", params: params
end
describe 'basic fetch' do
let!(:todo_list_item) { create(:todo_list_item) }
it 'works' do
expect(TodoListItemResource).to receive(:find).and_call_original
make_request
expect(response.status).to eq(200)
expect(d.jsonapi_type).to eq('todo_list_items')
expect(d.id).to eq(todo_list_item.id)
end
end
end
If we run our test provided by Graphiti we see green!
rspec spec/requests/api/v1/todo_list_items/show_spec.rb
Finished in 0.18498 seconds (files took 0.23766 seconds to load)
2 examples, 0 failures
Creating TodoListItems
Let’s create and execute the a new request in Insomnia to create a single TodoListItem
.
And of course we need to write/fix our test that was autogenerated by Graphiti.
spec/requests/api/v1/todo_list_items/create_spec.rb
require 'rails_helper'
RSpec.describe "todo_list_items#create", type: :request do
subject(:make_request) do
jsonapi_post "/api/v1/todo_list_items", payload
end
describe 'basic create' do
let(:params) { attributes_for(:todo_list_item) }
let!(:todo_list) { create(:todo_list) }
let(:payload) do
{
data: {
type: 'todo_list_items',
attributes: params,
relationships: {
todo_list: {
data: {
type: 'todo_lists',
id: todo_list.id
}
}
}
}
}
end
it 'works' do
expect(TodoListItemResource).to receive(:build).and_call_original
expect { make_request }.to change { TodoListItem.count }.by(1)
expect(response.status).to eq(201)
end
end
end
If we run our test provided we see green!
rspec spec/requests/api/v1/todo_list_items/create_spec.rb
Finished in 0.23428 seconds (files took 0.25014 seconds to load)
2 examples, 0 failures
Updating TodoListItems
Let’s create and execute the a new request in Insomnia to update a TodoListItem
.
And of course we need to write/fix our test that was autogenerated by Graphiti.
spec/requests/api/v1/todo_list_items/update_spec.rb
require 'rails_helper'
RSpec.describe "todo_list_items#update", type: :request do
subject(:make_request) do
jsonapi_put "/api/v1/todo_list_items/#{todo_list_item.id}", payload
end
describe 'basic update' do
let!(:todo_list_item) { create(:todo_list_item) }
let(:payload) do
{
data: {
id: todo_list_item.id.to_s,
type: 'todo_list_items',
attributes: {
content: "break the dishes!"
}
}
}
end
it 'updates the resource' do
expect(TodoListItemResource).to receive(:find).and_call_original
make_request
expect(response.status).to eq(200)
expect(todo_list_item.reload.content).to eq("break the dishes!")
end
end
end
If we run our test provided we see green!
rspec spec/requests/api/v1/todo_list_items/update_spec.rb
Finished in 0.30056 seconds (files took 0.25718 seconds to load)
2 examples, 0 failures
Destroying TodoListItems
Let’s create and execute the a new request in Insomnia to destroy a TodoListItem
.
And of course we need to write/fix our test that was autogenerated by Graphiti.
spec/requests/api/v1/todo_list_items/destroy_spec.rb
require 'rails_helper'
RSpec.describe "todo_list_items#destroy", type: :request do
subject(:make_request) do
jsonapi_delete "/api/v1/todo_list_items/#{todo_list_item.id}", {}
end
describe 'basic destroy' do
let!(:todo_list_item) { create(:todo_list_item) }
it 'destroys the resource' do
expect(TodoListItemResource).to receive(:find).and_call_original
expect { make_request}.to change { TodoListItem.count }.by(-1)
expect(response.status).to eq(200)
expect { todo_list_item.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(json).to eq('meta' => {})
end
end
end
If we run our test provided we see green!
rspec spec/requests/api/v1/todo_list_items/destroy_spec.rb
Finished in 0.38517 seconds (files took 0.56542 seconds to load)
2 examples, 0 failures
That covers all of our resources! Thanks for hanging in there with me while we stepped through this together. If you fast-forwarded, scrolled through, or encountered issues you can view the full repo here for Part 1.
I cannot wait to get into part 2 and show you all where JSON:API and Graphiti really shine. I’ll be covering some slightly more advanced topics such as side-posting, side-loading, filtering, sorting, and pagination.