# 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: ```ruby gem 'rspec-rails-api' ``` And then execute: ```sh bundle ``` ### Rails configuration Configuration should be made manually for now: **spec/support/rspec_rails_api.rb**: ```ruby 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: 'admin@example.com', 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.metadata[: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**: ```ruby #... require 'support/rspec_rails_api' #... ``` ## Configuration **TODO: This section is incomplete and the gem has no generator yet.** ```rb # 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: 'admin@example.com', 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: ```ruby # 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`: ```ruby #... # Add additional requires below this line. Rails is not loaded until this point! require 'support/devise' #... ``` Include the helper for acceptance specs: ```ruby RSpec.configure do |config| config.include DeviseAcceptanceSpecHelpers, type: :acceptance end ``` You can now use the methods as usual: ```ruby # 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](https://makandracards.com/makandra/37161-rspec-devise-how-to-sign-in-users-in-request-specs) by Arne Hartherz (MIT license). ## Writing specs There is a [commented example](dummy/spec/acceptance/posts_spec.rb) available in `dummy/spec/acceptance`. The idea is to have a simple DSL, and declare things like: **spec/acceptance/users_spec.rb** ```ruby 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: ```rb # 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: ```rb # 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: ```rb # 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: ```rb # 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 symbol - `description` is a hash of attributes ```ruby { id: { type: :integer, desc: 'The resource identifier' }, name: { type: :string, desc: 'The resource name' }, # ... } ``` An attribute should have the following form: ``` : {type: , desc: } ``` - `type` can be any of the accepted [OpenAPI types](http://spec.openapis.org/oas/v3.0.2#dataTypeFormat): - `:integer`, `:int32`, `:int64` - `:number`, `:float`, `:double` - `:string`, `:byte`, `:binary` - `:boolean` - `:date`, `:datetime` - `:password` - `:object`, `:array` - `description` should be some valid [CommonMark](https://commonmark.org/) ###### Objects and arrays To describe complex structures, use `:object` with `:attributes` and `:array` `:of` something: ```ruby 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](https://swagger.io/specification/#data-types) for the list. Usage: ```rb 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_(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 endpoint - `description` should be some valid [CommonMark](https://commonmark.org/) 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. ```ruby on_get '/api/users/:id/posts/:post_slug?full=:full_post' do path_params fields: { 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](https://commonmark.org/) - `required` is optional an defaults to `true`. Alternative with defined parameters: ```ruby 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: ```ruby 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: ``` : {type: , desc: , required: , attributes: , of: } ``` - `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`. - `attributes` is a hash of params and is only used if `type: :object` - `of` is a hash of params and is only used if `type: :array` Alternative with defined parameters: ```ruby 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 status](https://httpstat.us/) - `description` should be some valid [CommonMark](https://commonmark.org/). If not defined, a human readable translation of the `http_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. If `test_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`. ```ruby # ... 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](#Configuration)). ```rb # 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 requests - `headers`: a hash of custom headers. - `ignore_content_type`: whether to ignore response's content-type. By default, checks for a JSON response ```ruby 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: ```ruby #... 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: ```ruby #... 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. ```ruby 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: ```shell 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: ```shell 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](http://contributor-covenant.org) code of conduct. ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ## 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](https://gitlab.com/experimentslabs/rspec-rails-api/blob/master/CODE_OF_CONDUCT.md).