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 API s.

Describing our JSON API s 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!

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.

First we’ll add json-schema to our 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:

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

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

In 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" } } } } }

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:

In 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!