Rails does not automatically conform to the json-api specification. A library will be helpful to accomplish that. But let's first take a look at how the Rails API behaves on its own.

At the core of our domain logic, there are people and there are foods. The people have relationships with the foods that can be called "preferences". So a person has many preferences (some good some bad). A preference belongs to one person and one food, and may have additional attributes that I'm going to call "details" and make use of postgres jsonb data type. Later I might add models for tagging and categorizing some of these things, but for now we can use three models.

rails generate resource person name

rails generate resource food name

rails generate resource preference person:belongs_to food:belongs_to details:jsonb

rails db:migrate

I'm going to put something meaningful into the generated model specs:

require 'rails_helper'

RSpec.describe Food, type: :model do
  it 'can be created with just a name' do
    Food.create(name: 'ravioli')

    expect(Food.count).to eq(1)
  end
end
spec/models/food_spec.rb
require 'rails_helper'

RSpec.describe Person, type: :model do
  it 'can be created with just a name' do
    Person.create(name: 'Alonzo')

    expect(Person.count).to eq(1)
  end
end
spec/models/person_spec.rb
require 'rails_helper'

RSpec.describe Preference, type: :model do
  let(:alice) { Person.create(name: 'Alice') }
  let(:wheat) { Food.create(name: 'wheat') }

  it 'associates with one food and one person' do
    Preference.create(food: wheat, person: alice)

    expect(alice.preferences.count).to eq(1)
    expect(wheat.preferences.count).to eq(1)
    expect(alice.preferences.first.food).to eql(wheat)
  end

  it 'can include arbitrary details' do
    Preference.create(food: wheat, person: alice, details: { is_alergic: true })

    expect(alice.preferences.first.details).to eq({ 'is_alergic' => true })
  end
end
spec/models/preferences_spec.rb

Now when I run rails spec the first two pass, but the preferences spec is failing. That's because even though the Preference model knows that it belongs_to Food and belongs_to Person, the other models are not aware of this. Adding the line has_many :preferences to the food and person models makes my test pass.

That's out of the way, for now. I'm much more interested in the request specs. That is where I can really test if our API payloads and endpoints are adhering the json-api spec. First, here is a spec that captures the current state of affairs:

require 'rails_helper'

RSpec.describe 'Foods Request', type: :request do
  it 'returns an empty collection when there are no foods' do
    get '/foods'

    expect(response).to have_http_status(:success)
    expect(response.content_type).to eq('application/json; charset=utf-8')

    parsed_response = JSON.parse(response.body)
    expect(parsed_response).to be_an(Array)
    expect(parsed_response).to be_empty
  end

  it 'returns a collection of foods when there are foods' do
    Food.create(name: 'apples')
    Food.create(name: 'bananas')
    Food.create(name: 'chocolate')

    get '/foods'

    expect(response).to have_http_status(:success)
    expect(response.content_type).to eq('application/json; charset=utf-8')

    parsed_response = JSON.parse(response.body)
    expect(parsed_response).to be_an(Array)
    expect(parsed_response.length).to eq(3)

    names = parsed_response.map { |item| item['name'] }
    expect(names)
      .to contain_exactly('apples', 'bananas', 'chocolate')
  end
end
spec/requests/foods_request_spec.rb

It fails because the controller needs an index action:

class FoodsController < ApplicationController
  def index
    render json: Food.all
  end
end
app/controllers/foods_controller.rb

The default Rails Way of responding to a Get Index request, is to return the collection, as a JSON array. Something like this:

[
  {
    "id": 51,
    "name": "apples",
    "created_at": "2020-04-12T02:09:33.815Z",
    "updated_at": "2020-04-12T02:09:33.815Z"
  },
  {
    "id": 52,
    "name": "bananas",
    "created_at": "2020-04-12T02:09:33.818Z",
    "updated_at": "2020-04-12T02:09:33.818Z"
  },
  {
    "id": 53,
    "name": "chocolate",
    "created_at": "2020-04-12T02:09:33.820Z",
    "updated_at": "2020-04-12T02:09:33.820Z"
  }
]

But according to the json:api specification, I want something like:

{
  "data": [
    {
      "type": "foods",
      "id": 51,
      "attributes": {
        "name": "apples"
      },
      "relationships": {
      },
      "links": {
        "self": "http://localhost:3000/foods/51"
      }
    },
    {
      "type": "foods",
      "id": 52,
      "attributes": {
        "name": "bananas"
      },
      "relationships": {
        "preferences": {
          "links": {
            "self": "http://localhost:3000/foods/52/relationships/preferences",
            "related": "http://localhost:3000/foods/52/preferences"
          },
          "data": [
            {
              "type": "preferences",
              "id": "5"
            },
            {
              "type": "preferences",
              "id": "12"
            }
          ]
        }
      },
      "links": {
        "self": "http://localhost:3000/foods/52"
      }
    },
    {
      "type": "foods",
      "id": 53,
      "attributes": {
        "name": "chocolate"
      },
      "relationships": {
        "preferences": {
          "links": {
            "self": "http://localhost:3000/foods/53/relationships/preferences",
            "related": "http://localhost:3000/foods/53/preferences"
          },
          "data": [
            {
              "type": "preferences",
              "id": "15"
            }
          ]
        }
      },
      "links": {
        "self": "http://localhost:3000/foods/53"
      }
    }
  ]
}

The json:api specification gives us a whole lot more information about the records, including what kinds of relationships exist. Optionally the related records themselves can be included in the response. This is going to be very useful in the future when we want to quickly check a recipe or ingredient list against a collection of people in just a few requests. But how do we get there?

jsonapi.org currently lists 7 libraries as Ruby on Rails server implementations. A quick look at the code repos for each helps winnow the field. JSONAPI_Suite cannot be found. jsonapi_for_rails was last updated in 2017. active_model_serializers and caprese were last updated in 2018. jsonapi-utils was updated in June 2019, but looks promising. However jsonapi-resources is bar far the most popular and up-to-date of the group, and has extensive documentation. I'll try it.

Somewhere in the Gemfile, add:

# ...

gem 'jsonapi-resources', '~> 0.10.2'

# ...
Gemfile
bundle install

Then, following some of the advice on the jsonapi-resources usage guide, I make two more changes:

class ApplicationController < ActionController::API
  include JSONAPI::ActsAsResourceController
end
app/controllers/application_controller.rb
  # ...
  
  # Eager load code on boot so JSONAPI-Resources resources are loaded and
  # processed globally
  config.eager_load = true

  # This will prevent the server from returning the HTML formatted error
  # messages when an exception happens.
  config.consider_all_requests_local = false
  
  # ...
config/environments/development.rb

jsonapi-resources requires a few configurations to be made for each model, or resource. I have to create these three files:

class FoodResource < JSONAPI::Resource
  attributes :name
  has_many :preferences
end
app/resources/food_resource.rb
class PersonResource < JSONAPI::Resource
  attributes :name
  has_many :preferences
end
app/resources/person_resource.rb
class PreferenceResource < JSONAPI::Resource
  attributes :details
  has_one :food
  has_one :person
en
app/resources/preference_resource.rb

And modify the routes:

Rails.application.routes.draw do
  jsonapi_resources :foods
  jsonapi_resources :people
  jsonapi_resources :preferences
end
config/routes.rb

I expected my request spec to fail, but it didn't. This is because my individual controllers inherit the jsonapi-resources behavior, but I am overwriting it with my own index method. I can simply remove that method from my foods_controller.rb and now the test fails like it should.

class FoodsController < ApplicationController
end
app/controllers/foods_controller.rb

I updated my test to conform to the jsonapi-resources way of doing things, and thus to json:api.

require 'rails_helper'

RSpec.describe 'Foods Request', type: :request do
  it 'returns an empty collection when there are no foods' do
    get '/foods'

    expect(response).to have_http_status(:success)
    expect(response.content_type).to eq('application/vnd.api+json')

    response_hash = JSON.parse(response.body).deep_symbolize_keys
    expect(response_hash).to be_a(Hash)
    expect(response_hash).to have_key(:data)
    expect(response_hash[:data]).to be_empty
  end

  it 'returns a collection of foods when there are foods' do
    apples = Food.create(name: 'apples')
    bananas = Food.create(name: 'bananas')
    chocolate = Food.create(name: 'chocolate')

    get '/foods'

    expect(response).to have_http_status(:success)
    expect(response.content_type).to eq('application/vnd.api+json')

    response_hash = JSON.parse(response.body).deep_symbolize_keys
    expect(response_hash).to be_a(Hash)
    expect(response_hash).to have_key(:data)
    expect(response_hash[:data].length).to eq(3)

    attrs = response_hash[:data].map { |item| item[:attributes] }
    expect(attrs).to contain_exactly(
      { name: 'apples' }, { name: 'bananas' }, { name: 'chocolate' }
    )

    ids = response_hash[:data].map { |item| item[:id].to_i }
    expect(ids).to contain_exactly(apples.id, bananas.id, chocolate.id)
  end
end
spec/requests/foods_request_spec.rb

Now all my tests are passing and I'm using an implementation of json:api!

But this test feels verbose and clumsy. I'm going to dedicate some time to improving my overall testing strategy, which will include factories, fakers, automatic re-testing, and some custom RSpec matchers.