RspecRailsApiDoc

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

Installation

As the gem is not yet published, you have to specify its git repository in order to test it.

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/acceptance_helper.rb

require 'rails_helper'
require 'rspec_rails_api'

RSpec.configure do |config|
  config.include Rspec::Rails::Api::DSL::Example
end

renderer = RSpec::Rails::Api::OpenApiRenderer.new
renderer.api_servers = [{ url: 'https://example.com' }]
renderer.api_title = 'A nice API for a nice application'
renderer.api_version = '1'
renderer.api_description = 'Access update data in this project'
# renderer.api_tos = 'http://example.com/tos.html'
# renderer.api_contact = { name: 'Admin', email: '[email protected]', 'http://example.com/contact' } 
# renderer.api_license = { name: 'Apache', url: 'https://opensource.org/licenses/Apache-2.0' }

RSpec.configuration.after(:context, type: :acceptance) do |context|
  renderer.merge_context context.class.metadata[:rrad].to_h
end

RSpec.configuration.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

spec/rails_helper.rb

# ...

RSpec::Rails::DIRECTORY_MAPPINGS[:acceptance] = %w[spec acceptance]

RSpec.configure do |config|
  # ...
  config.include RSpec::Rails::RequestExampleGroup, :type => :acceptance
end

Configuration

TODO

Usage

Write some spec files and run RSpec as you would usually do.

If you want to generate the documentation without testing the endpoints (and thus, without examples in generated files), use the DOC_ONLY environment variable:

DOC_ONLY=true bundle exec rails spec

For now, files are saved as tmp/out.json and tmp/out.yml.

There is nothing to customize the file headers (info, license, ...) yet.

Writing specs

There is a commented example available in doc/.

The idea is to have a simple DSL, and declare things like:

spec/acceptance/users_spec.rb

require 'acceptance_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' do |example|
      visit example
      expect(response).to have_many defined :user
    end
  end

  on_put '/api/users/:id', 'Users list' do
    path_param id: { type: :integer, description: 'User Id' }

    request_params user: {
      type: :object, required: true, properties: {
        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' do |example|
      visit example
      expect(response).to have_one defined :user
    end
  end
end

DSL

Example groups

resource(name, description)

Starts a resource description.

  • It must be called before any other documentation calls.
  • It should be in the first describe block
entity(name, fields)

Describes an entity for the documentation. The name is not visible, so you can put whatever fits (i.e: :account, :user if the content differs)

They are ideally in the main describe block.

  • name is a symbol
  • description 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.

on_<xxx>(url, description, &block)

Defines an URL.

  • url should be a relative URL to an existing endpoint (i.e.: /api/users)
  • description should be some valid CommonMark

For now, only these methods are available:

  • on_get
  • on_post
  • on_put
  • on_patch
  • on_delete
path_params(<hash_of_attributes>)

Defines the path parameters that are used in the URL.

on_get '/api/users/:id/posts/:post_slug?full=:full_post' do
  path_params id:        type: :integer, description: 'The user ID',
              post_slug: type: :string, description: 'The post slug',
              full_post: type: :boolean, required: false, description: 'Returns the full post if `true`, or only an excerpt',

  # ...
end
  • type is the field type (check entity definition for a list).
  • description should be some valid CommonMark
  • required is optional an defaults to true.
request_params(<hash_of_attributes>)

Defines the format of the JSON payload. Type object is supported, so nested elements can be described:

on_post '/api/items' do
  request_params item: { type: :object, required: true, properties: {
                         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>, properties: <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 to false.
  • properties is a hash of params and is only used if type: :object
for_code(http_status, description, doc_only: false &block)

Describes the desired output for a precedently defined URL.

Block takes one required argument, that should be passed to visit. This argument will contain the block context and allow visit to access the metadatas.

  • http_status is an integer representing an HTTP status
  • description should be some valid CommonMark
  • doc_only can be set to true to temporarily disable block execution and only create the documentation (without examples).
  • block where additional tests can be performed. If visit() 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 visit.

# ...
  for_code 200 'A successful response' do |example|
    visit example
    # ...
  end
# ...

Examples

Example methods are available in for_code blocks

visit(example, path_params: {}, payload: {}, headers: {})

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 requests

  • headers: a hash of custom headers.

for_code 200, 'Success' do |example|
  visit example
end

Matchers

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.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

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.