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
:
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.
Defining the user 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" }
}
}
}
}
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:
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!
What’s next
- Read more about JSON Schema in this overview by Brandur Leach.
- Get inspired by the Space Telescope Science Institute’s guide to structuring schemas using JSON Schema.