Validating JSON Schemas with an RSpec Matcher

At thoughtbot we’ve been experimenting with using JSON Schema, a widely-used specification for describing the structure of JSON objects, to improve workflows for documenting and validating JSON APIs.

Describing our JSON APIs using the JSON Schema standard allows us to automatically generate and update our HTTP clients using tools such as heroics for Ruby and Schematic for Go, saving loads of time for client developers who are depending on the API. It also allows us to improve test-driven development of our API.

If you’ve worked on a test-driven JSON API written in Ruby before, you’ve probably encountered a request spec that looks like this:

describe "Fetching the current user" do
  context "with valid auth token" do
    it "returns the current user" do
      user = create(:user)
      auth_header = { "Auth-Token" => user.auth_token }

      get v1_current_user_url, {}, auth_header

      current_user = response_body["user"]
      expect(response.status).to eq 200
      expect(current_user["auth_token"]).to eq user.auth_token
      expect(current_user["email"]).to eq user.email
      expect(current_user["first_name"]).to eq user.first_name
      expect(current_user["last_name"]).to eq user.last_name
      expect(current_user["id"]).to eq user.id
      expect(current_user["phone_number"]).to eq user.phone_number
    end
  end

  def response_body
    JSON.parse(response.body)
  end
end

Following the four-phase test pattern, the test above executes a request to the current user endpoint and makes some assertions about the structure and content of the expected response. While this approach has the benefit of ensuring the response object includes the expected values for the specified properties, it is also verbose and cumbersome to maintain.

Wouldn’t it be nice if the test could look more like this?

describe "Fetching the current user" do
  context "with valid auth token" do
    it "returns the current user" do
      user = create(:user)
      auth_header = { "Auth-Token" => user.auth_token }

      get v1_current_user_url, {}, auth_header

      expect(response.status).to eq 200
      expect(response).to match_response_schema("user")
    end
  end
end

Well, with a dash of RSpec and a pinch of JSON Schema, it can!

Leveraging the flexibility of RSpec and JSON Schema

An important feature of JSON Schema is instance validation. Given a JSON object, we want to be able to validate that its structure meets our requirements as defined in the schema. As providers of an HTTP JSON API, our most important JSON instances are in the response body of our HTTP requests.

RSpec provides a DSL for defining custom spec matchers. The json-schema gem’s raison d'ĂȘtre is to provide Ruby with an interface for validating JSON objects against a JSON schema.

Together these tools can be used to create a test-driven process in which changes to the structure of your JSON API drive the implementation of new features.

Creating the custom matcher

First we’ll add json-schema to our Gemfile:

Gemfile

group :test do
  gem "json-schema"
end

Next, we’ll define a custom RSpec matcher that validates the response object in our request spec against a specified JSON schema:

spec/support/api_schema_matcher.rb

RSpec::Matchers.define :match_response_schema do |schema|
  match do |response|
    schema_directory = "#{Dir.pwd}/spec/support/api/schemas"
    schema_path = "#{schema_directory}/#{schema}.json"
    JSON::Validator.validate!(schema_path, response.body, strict: true)
  end
end

We’re making a handful of decisions here: We’re designating spec/support/api/schemas as the directory for our JSON schemas and we’re also implementing a naming convention for our schema files.

JSON::Validator#validate! is provided by the json-schema gem. Passing strict: true to the validator ensures that validation will fail when an object contains properties not defined in the schema.

Defining the user schema

Finally, we define the user schema using the JSON Schema specification:

spec/support/api/schemas/user.json

{
  "type": "object",
  "required": ["user"],
  "properties": {
    "user" : {
      "type" : "object",
      "required" : [
        "auth_token",
        "email",
        "first_name",
        "id",
        "last_name",
        "phone_number"
      ],
      "properties" : {
        "auth_token" : { "type" : "string" },
        "created_at" : { "type" : "string", "format": "date-time" },
        "email" : { "type" : "string" },
        "first_name" : { "type" : "string" },
        "id" : { "type" : "integer" },
        "last_name" : { "type" : "string" },
        "phone_number" : { "type" : "string" },
        "updated_at" : { "type" : "string", "format": "date-time" }
      }
    }
  }
}

TDD, now with schema validation

Let’s say we need to add a new property, neighborhood_id, to the user response object. The back end for our JSON API is a Rails application using ActiveModel::Serializers.

We start by adding neighborhood_id to the list of required properties in the user schema:

spec/support/api/schemas/user.json

{
  "type": "object",
  "required": ["user"],
  "properties":
    "user" : {
      "type" : "object",
      "required" : [
        "auth_token",
        "created_at",
        "email",
        "first_name",
        "id",
        "last_name",
        "neighborhood_id",
        "phone_number",
        "updated_at"
      ],
      "properties" : {
        "auth_token" : { "type" : "string" },
        "created_at" : { "type" : "string", "format": "date-time" },
        "email" : { "type" : "string" },
        "first_name" : { "type" : "string" },
        "id" : { "type" : "integer" },
        "last_name" : { "type" : "string" },
        "neighborhood_id": { "type": "integer" },
        "phone_number" : { "type" : "string" },
        "updated_at" : { "type" : "string", "format": "date-time" }
      }
    }
  }
}

Then we run our request spec to confirm that it fails as expected:

Failures:

  1) Fetching a user with valid auth token returns requested user
     Failure/Error: expect(response).to match_response_schema("user")
     JSON::Schema::ValidationError:
       The property '#/user' did not contain a required property of 'neighborhood_id' in schema
       file:///Users/laila/Source/thoughtbot/json-api/spec/support/api/schemas/user.json#

Finished in 0.34306 seconds (files took 3.09 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/requests/api/v1/users_spec.rb:6 # Fetching a user with valid auth token returns requested user

We make the test pass by adding a neighborhood_id attribute in our serializer:

class Api::V1::UserSerializer < ActiveModel::Serializer
  attributes(
    :auth_token,
    :created_at,
    :email,
    :first_name,
    :id,
    :last_name,
    :neighborhood_id,
    :phone_number,
    :updated_at
  )
end
.

Finished in 0.34071 seconds (files took 3.14 seconds to load)
1 example, 0 failures

Top 1 slowest examples (0.29838 seconds, 87.6% of total time):
  Fetching a user with valid auth token returns requested user
    0.29838 seconds ./spec/requests/api/v1/users_spec.rb:6

Hooray!

What’s next