RSpec-rails-api
An RSpec plugin to test Rails API responses and generate swagger documentation
This is a work in progress but you're welcome to help, test, submit issues, ...
Note For Rails 5, use version 0.2.3
Installation
Add this line to your application's Gemfile:
gem 'rspec-rails-api'
And then execute:
bundle
Rails configuration
Configuration should be made manually for now:
spec/support/rspec_rails_api.rb:
require 'rspec_rails_api'
# Associate spec/acceptance/* to acceptance tests
RSpec::Rails::DIRECTORY_MAPPINGS[:acceptance] = %w[spec acceptance]
RSpec.configure do |config|
config.include RSpec::Rails::Api::DSL::Example
config.include RSpec::Rails::RequestExampleGroup, type: :acceptance
# Define the renderer if you want to generate the OpenApi documentation
renderer = RSpec::Rails::Api::OpenApiRenderer.new
# Options here should be customized
renderer.api_title = 'YourProject API'
renderer.api_version = '1'
renderer.api_description = 'Manage data on YourProject'
# Options below are optional
renderer.api_servers = [{ url: 'https://example.com' }]
renderer.api_tos = 'http://example.com/tos.html'
renderer.api_contact = { name: 'Admin', email: '[email protected]', url: 'http://example.com/contact' }
renderer.api_license = { name: 'Apache', url: 'https://opensource.org/licenses/Apache-2.0' }
# ... Check the "Configuration" section for all the options
config.after(:context, type: :acceptance) do |context|
renderer.merge_context context.class.[:rra].to_h
end
config.after(:suite) do
# Default path is 'tmp/rspec_rails_api_output.json/yaml'
renderer.write_files Rails.root.join('public', 'swagger_doc'), only: [:json]
end
end
spec/rails_helper.rb:
#...
require 'support/rspec_rails_api'
#...
Configuration
TODO: This section is incomplete and the gem has no generator yet.
# Server URL for quick reference
server_url = 'https://example.com'
# Options here should be present for a valid OpenAPI file
renderer.api_title = 'MyProject API'
renderer.api_version = '1'
# Options below are optional
#
# API description. Markdown supported
# renderer.api_description = 'Manage data on MyProject'
#
# List of servers, to live-test the documentation
# renderer.api_servers = [{ url: server_url }, { url: 'http://localhost:3000' }]
#
# Link to the API terms of service, if any
# renderer.api_tos = 'http://example.com/tos.html'
#
# Contact information
# renderer.api_contact = { name: 'Admin', email: '[email protected]', url: 'http://example.com/contact' }
#
# API license information
# renderer.api_license = { name: 'Apache', url: 'https://opensource.org/licenses/Apache-2.0' }
#
# Possible security schemes
# renderer.add_security_scheme :pkce_code_grant, 'PKCE code grant',
# type: 'oauth2',
# flows: {
# implicit: {
# authorizationUrl: "#{server_url}/oauth/authorize",
# scopes: { read: 'will read data on your behalf', write: 'will write data on your behalf' }
# }
# }
# renderer.add_security_scheme :bearer, 'Bearer token',
# type: 'http',
# scheme: 'bearer'
#
# Declare keys whose values should be filtered in responses.
# renderer.redact_responses entity_name: { key: 'REDACTED' },
# other_entity: { other_key: ['REDACTED'] }
# We need to merge each context metadata so we can reference to them to build the final file
RSpec.configuration.after(:context, type: :acceptance) do |context|
renderer.merge_context context.class.metadata[:rra].to_h
# During development of rspec_rails_api, you may want to dump raw metadata to a file
renderer.merge_context context.class.metadata[:rra].to_h, dump_metadata: true
end
# Skip this block if you don't need the OpenAPI documentation file and only have your responses tested
RSpec.configuration.after(:suite) do
renderer.write_files Rails.root.join('public/swagger') # Write both YAML and prettified JSON files
# or
renderer.write_files Rails.root.join('public/swagger'), only: [:json] # Prettified JSON only
# or
renderer.write_files Rails.root.join('public/swagger'), only: [:yaml] # YAML only
end
Integration with Devise
To use sign_in
and sign_out
from Devise in the acceptance tests, create a Devise support file:
# spec/support/devise.rb
module DeviseAcceptanceSpecHelpers
include Warden::Test::Helpers
def sign_in(resource_or_scope, resource = nil)
resource ||= resource_or_scope
scope = Devise::Mapping.find_scope!(resource_or_scope)
login_as(resource, scope: scope)
end
def sign_out(resource_or_scope)
scope = Devise::Mapping.find_scope!(resource_or_scope)
logout(scope)
end
end
Load this file in rails_helper.rb
:
#...
# Add additional requires below this line. Rails is not loaded until this point!
require 'support/devise'
#...
Include the helper for acceptance specs:
RSpec.configure do |config|
config.include DeviseAcceptanceSpecHelpers, type: :acceptance
end
You can now use the methods as usual:
# In a before block
before do
sign_in #...
end
# In examples
#...
for_code 200, 'Success' do |url|
sign_in #...
test_response_of url
#...
end
#...
This solution comes from this article by Arne Hartherz (MIT license).
Writing specs
There is a commented example available in
dummy/spec/acceptance
.
The idea is to have a simple DSL, and declare things like:
spec/acceptance/users_spec.rb
require 'rails_helper'
RSpec.describe 'Users', type: :acceptance do
resource 'Users', 'Manage users'
entity :user,
id: { type: :integer, description: 'The id' },
email: { type: :string, description: 'The name' },
role: { type: :string, description: 'The name' },
created_at: { type: :datetime, description: 'Creation date' },
updated_at: { type: :datetime, description: 'Modification date' },
url: { type: :string, description: 'URL to this category' }
on_get '/api/users/', 'Users list' do
for_code 200, 'Success response', expect_many: :user do |url|
test_response_of url
end
end
on_put '/api/users/:id', 'Users list' do
path_param id: { type: :integer, description: 'User Id' }
request_params user: {
type: :object, attributes: {
name: { type: :string, required: false, description: 'New name' },
email: { type: :string, required: false, description: 'New email' },
role: { type: :string, required: false, description: 'New role' },
}
}
for_code 200, 'Success response', expect_one: :user do |url|
test_response_of url
end
end
end
Entity declarations
You can declare entities locally (in every spec files), but sometimes you will need to use/reference the same entity in multiple spec files (e.g.: an error message). In that case, you can create global entities in separate files, and they will be picked-up when needed.
Example of a local entity:
# spec/acceptance/api/users_acceptance_spec.rb
require 'rails_helper'
RSpec.describe 'Users', type: :acceptance do
resource 'Users', 'Manage users'
# This is a local entity
entity :user,
id: { type: :integer, description: 'The id' },
email: { type: :string, description: 'The name' },
role: { type: :string, description: 'The name' },
created_at: { type: :datetime, description: 'Creation date' },
updated_at: { type: :datetime, description: 'Modification date' },
url: { type: :string, description: 'URL to this category' }
on_get '/api/users/', 'Users list' do
for_code 200, 'Success response', expect_many: :user do |url|
test_response_of url
end
end
#...
end
Defining global entities:
# spec/support/entities/user.rb
# This file should be required at some point in the "rails_helper" or "acceptance_helper"
require 'rspec/rails/api/metadata'
RSpec::Rails::Api::Metadata.add_entity :user,
id: { type: :integer, description: 'The id' },
email: { type: :string, description: 'The name' },
role: { type: :string, description: 'The name' },
created_at: { type: :datetime, description: 'Creation date' },
updated_at: { type: :datetime, description: 'Modification date' },
url: { type: :string, description: 'URL to this category' }
Organization of the global entities declaration is up to you.
For small projects, we usually put them all in one file:
# spec/support/acceptance_entities.rb
require 'rspec/rails/api/metadata'
# This file contains common object definitions
{
error: {
error: { type: :string, description: "Error message" }
},
form_error: {
title: { type: :array, required: false, description: "Title errors", of: :string }
},
}.each do |name, attributes|
RSpec::Rails::Api::Metadata.add_entity name, attributes
end
DSL
Example groups
resource(type, description)
Starts a resource description.
- It must be called before any other documentation calls.
- It should be in the first
describe block
A resource may be completed across multiple spec files:
# an_acceptance_spec.rb
RSpec.describe 'Something', type: :acceptance do
resource 'User', 'Manage users'
end
# another_acceptance_spec.rb
RSpec.describe 'Something else', type: :acceptance do
resource 'User', 'Another description'
end
The first evaluated resource
statement will be used as description; all the tests in both files will complete it.
entity(type, fields)
Describes an entity for the documentation. The type is only a reference,
you can put whatever fits (i.e: :account
, :user
, ...).
They should be in the main describe
block.
type
is a symboldescription
is a hash of attributes
{
id: { type: :integer, desc: 'The resource identifier' },
name: { type: :string, desc: 'The resource name' },
# ...
}
An attribute should have the following form:
<field_name>: {type: <type>, desc: <description>}
type
can be any of the accepted OpenAPI types::integer
,:int32
,:int64
:number
,:float
,:double
:string
,:byte
,:binary
:boolean
:date
,:datetime
:password
:object
,:array
description
should be some valid CommonMark
Objects and arrays
To describe complex structures, use :object
with :attributes
and
:array
:of
something:
entity :friend,
name: { type: :string, required: false, description: 'Friend name' }
entity :user,
id: { type: :number, required: false, description: 'Identifier' },
name: { type: :string, required: false, description: 'The name' },
friends: { type: :array, of: :friend, required: false, description: 'Friends list'},
dog: { type: :object, required: false, description: 'The dog', attributes: :dog },
cat: {
type: :object, required: false, description: 'The cat', attributes: {
name: { type: :string, required: false, description: 'Cat name' },
}
}
In this example, there is an :array, of: :friend
, which is a reference
to the :friend
entity described above; an :object
with :dog
attributes (reference too); and a cat object with its attributes defined
inline.
Both :of
and attributes
may be a hash of fields or a symbol. If they
are omitted, they will be documented, but responses won't be validated.
Arrays of primitives are supported; refer to the documentation for the list. Usage:
entity :user,
favorite_numbers: { type: :array, of: :int32 }
Check lib/rspec_rails_api.rb
for the full list.
parameters(type, fields)
Describe path or request parameters. The type is only a reference,
use whatever makes sense. These parameters will be present in
documentation, only if they are referenced by a request_params
or
path_params
call.
Fields have the structure of the hash you would give to request_params
or path_params
(see each method later in this documentation).
on_<xxx>(url, summary = nil, description = nil, &block)
Defines an URL.
url
should be a relative URL to an existing endpoint (i.e.:/api/users
)summary
is a one line description of the endpointdescription
should be some valid CommonMark
These methods are available:
on_get
on_post
on_put
on_patch
on_delete
path_params(fields: nil, defined: nil)
Defines the path parameters that are used in the URL.
Any field that is not referenced in the URL will end in querystring parameters.
on_get '/api/users/:id/posts/:post_slug' do
path_params fields: {
# "path" parameters
id: { type: :integer, description: 'The user ID' },
post_slug: { type: :string, description: 'The post slug' },
# "query" parameters
full_post: { type: :boolean, required: false, description: 'Returns the full post if `true`, or only an excerpt' },
}
# ...
end
If the querystring parameters are visible in the URL for some reason, they will still be treated as query
parameters:
on_get '/api/users/:id?full_bio=:full_bio_flag' do
path_params fields: {
# "path" parameters
id: { type: :integer, description: 'The user ID' },
# "query" parameters
full_bio_flag: { type: :boolean, required: false, description: 'Returns the full biography' },
}
# ...
end
type
is the field type (check entity definition for a list).description
should be some valid CommonMarkrequired
is optional and defaults totrue
.
Alternative with defined parameters:
parameters :users_post_path_params,
id: { type: :integer, description: 'The user ID' },
post_slug: { type: :string, description: 'The post slug' }
on_get '/api/users/:id/posts/:post_slug' do
path_params defined: :users_post_path_params
#...
end
request_params(attributes: nil, defined: nil)
Defines the format of the JSON payload. Type object
is supported, so
nested elements can be described:
on_post '/api/items' do
request_params attributes: {
item: { type: :object, attributes: {
name: { type: integer, description: 'The name of the new item', required: true },
notes: { type: string, description: 'Additional notes' }
} }
}
#...
end
An attribute should have the following form:
<attr_name>: {type: <type>, desc: <description>, required: <required>, attributes: <another_hash>, of: <another_hash> }
attr_name
is the attribute name (sic)type
is the field type (check entity definition for a list).type
can be:object
if the attribute contains other attributes.required
is optional an defaults tofalse
.attributes
is a hash of params and is only used iftype: :object
of
is a hash of params and is only used iftype: :array
Alternative with defined parameters:
parameters :item_form_params,
item: { type: :object, attributes: {
name: { type: integer, description: 'The name of the new item', required: true },
notes: { type: string, description: 'Additional notes' }
}
}
on_post '/api/items' do
request_params defined: :item_form_params
#...
end
for_code(http_status, description = nil, test_only: false &block)
Describes the desired output for a precedently defined URL.
Block takes one required argument, that should be passed to test_response_of
.
This argument will contain the block context and allow test_response_of
to access
the metadatas.
You can have only one documented code per action/url, unless you use
test_only
.
http_status
is an integer representing an HTTP statusdescription
should be some valid CommonMark. If not defined, a human readable translation of thehttp_status
will be used.test_only
will omit the test from the documentation. Useful when you need to test things around the call (response content, db,...)block
where additional tests can be performed. Iftest_response_of
is called within the block, its output will be used in documentation examples, and the response type and code will actually be tested.
If no block is passed, only the documentation will be generated, without examples. This can be useful to document endpoints that are impossible to test.
Once again, you have to pass an argument to the block if you use
test_response_of
.
# ...
for_code 200, 'A successful response' do |url|
test_response_of url
# ...
end
for_code 200, 'Side test', test_only: true do |url|
test_response_of url
# ...
end
# ...
requires_security(scheme_references)
Specifies the valid security schemes to use for this request. Security schemes are declared at the renderer level (see the configuration example).
# Given a previously :basic scheme
# ...
on_get '/some/path' do
require_security :basic, :implicit
for_code 200 do |url|
#...
end
end
# ...
Examples
Example methods are available in for_code
blocks
test_response_of(example, path_params: {}, payload: {}, headers: {}, ignore_content_type: false)
Visits the described URL and:
- Expects the response code to match the described one
Expects the content type to be
application/json
example
is required and should be the block context (yep, i'll never say it enough)path_params
: a hash of overrides for path params (useful if a custom value is needed)payload
: a hash of values to send. Ignored for GET and DELETE requestsheaders
: a hash of custom headers.ignore_content_type
: whether to ignore response's content-type. By default, checks for a JSON response
for_code 200, 'Success' do |url|
test_response_of url
end
Matchers
The matchers should not be used in acceptance specs unless the default DSL is not adapted for a particular use case (and I can't imagine one now).
For the sake of comprehension, the two custom matchers still described.
have_one(type)
Expects the compared content to be a hash with the same keys as a defined entity.
It should be compared against a hash or a response
object:
#...
entity :user,
id: { type: :integer, desc: 'The id' },
name: { type: :string, desc: 'The name' }
#...
expect({name: 'John'}).to have_one defined :user # Fails because `id` is missing
# OR
expect(response).to have_one defined :user
defined
will get the correct entity.
have_many(type)
Expects the compared content to be an array of hashes with the same keys as a defined entity.
It should be compared against an array or a response
object:
#...
entity :user,
id: { type: :integer, desc: 'The id' },
name: { type: :string, desc: 'The name' }
#...
expect([{id: 2, name: 'Jessica'}, {name: 'John'}]).to have_many defined :user # Fails because `id` is missing in the second entry
# OR
expect(response).to have_many defined :user
defined
will get the correct entity.
Limitations
Contexts
Contexts will break the thing. This is due to how the gem builds its metadata, relying on the parents metadata. You have to stick to the DSL.
RSpec.describe 'Categories', type: :request do
describe 'Categories'
context 'Authenticated' do
on_get '/api/categories', 'List all categories' do
# ...
end
end
# or
on_get '/api/categories', 'List all categories' do
context 'Authenticated' do
# ...
end
end
# won't work as expected.
end
MRs to change this are welcome.
Request parameters
Arrays of objects are not supported yet (i.e.: to describe nested
attributes of an has_many
relation)
MRs to improve this are welcome.
Files
There is no support for file fields yet.
Security
Security
mechanisms are only declared for the OpenApi documentation as a reference, and is
not checked nor enforced in the tests.
Development
After checking out the repo, run bin/setup
to install dependencies.
Then, run bundle exec rspec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
Testing
Dummy application
A small Rails application is available as an example, in the dummy
directory.
If you write new features in the library, you'll have to update the examples to
make them pass their tests:
cd dummy
bundle exec rspec
Doing so will also update the fixtures used by some of the library tests.
Please don't commit the fixtures unless the changes in them are directly related to the changes in the library.
Code linting
We use Rubocop here, with a releset for the library, and another for the dummy application:
bundle exec rubocop
cd dummy
bundle exec rubocop
Contributing
Bug reports and pull requests are welcome on GitLab at https://gitlab.com/experimentslabs/rspec-rails-api/issues. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the RSpecRailsApiDoc project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.