rspec-openapi Gem Version test codecov Ruby-toolbox DeepWiki

Generate OpenAPI schema from RSpec request specs.

What's this?

There are some gems which generate OpenAPI specs from RSpec request specs. However, they require a special DSL specific to these gems, and we can't reuse existing request specs as they are.

Unlike such existing gems, rspec-openapi can generate OpenAPI specs from request specs without requiring any special DSL. Furthermore, rspec-openapi keeps manual modifications when it merges automated changes to OpenAPI specs in case we can't generate everything from request specs.

Installation

Add this line to your application's Gemfile:

gem 'rspec-openapi', group: :test

Usage

Run rspec with OPENAPI=1 to generate doc/openapi.yaml for your request specs.

$ OPENAPI=1 bundle exec rspec

Example

Let's say you have a request spec like this:

RSpec.describe 'Tables', type: :request do
  describe '#index' do
    it 'returns a list of tables' do
      get '/tables', params: { page: '1', per: '10' }, headers: { authorization: 'k0kubun' }
      expect(response.status).to eq(200)
    end

    it 'does not return tables if unauthorized' do
      get '/tables'
      expect(response.status).to eq(401)
    end
  end

  # ...
end

If you run the spec with OPENAPI=1,

OPENAPI=1 bundle exec rspec spec/requests/tables_spec.rb

It will generate doc/openapi.yaml file like:

openapi: 3.0.3
info:
  title: rspec-openapi
paths:
  "/tables":
    get:
      summary: index
      tags:
        - Table
      parameters:
        - name: page
          in: query
          schema:
            type: integer
          example: 1
        - name: per
          in: query
          schema:
            type: integer
          example: 10
      responses:
        '200':
          description: returns a list of tables
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
                    # ...

and the schema file can be used as an input of Swagger UI or Redoc.

Redoc example

Configuration

The following configurations are optional.

require 'rspec/openapi'

# Change the path to generate schema from `doc/openapi.yaml`
RSpec::OpenAPI.path = 'doc/schema.yaml'

# Change the output type to JSON
RSpec::OpenAPI.path = 'doc/schema.json'

# Or generate multiple partial schema files, given an RSpec example
RSpec::OpenAPI.path = -> (example) {
  case example.file_path
  when %r[spec/requests/api/v1/] then 'doc/openapi/v1.yaml'
  when %r[spec/requests/api/v2/] then 'doc/openapi/v2.yaml'
  else 'doc/openapi.yaml'
  end
}

# Change the default title of the generated schema
RSpec::OpenAPI.title = 'OpenAPI Documentation'

# Or generate individual titles for your partial schema files, given an RSpec example
RSpec::OpenAPI.title = -> (example) {
  case example.file_path
  when %r[spec/requests/api/v1/] then 'API v1 Documentation'
  when %r[spec/requests/api/v2/] then 'API v2 Documentation'
  else 'OpenAPI Documentation'
  end
}

# Disable generating `example` globally
RSpec::OpenAPI.enable_example = false

# Customize example name generation (used for multiple examples)
RSpec::OpenAPI.example_name_builder = -> (example) { example.description }

# Disable generating example summaries for `examples`
RSpec::OpenAPI.enable_example_summary = false

# Change `info.version`
RSpec::OpenAPI.application_version = '1.0.0'

# Set the info header details
RSpec::OpenAPI.info = {
  description: 'My beautiful API',
  license: {
    'name': 'Apache 2.0',
    'url': 'https://www.apache.org/licenses/LICENSE-2.0.html'
  }
}

# Set request `headers` - generate parameters with headers for a request
RSpec::OpenAPI.request_headers = %w[X-Authorization-Token]

# Set response `headers` - generate parameters with headers for a response
RSpec::OpenAPI.response_headers = %w[X-Cursor]

# Set `servers` - generate servers of a schema file
RSpec::OpenAPI.servers = [{ url: 'http://localhost:3000' }]

# Set `security_schemes` - generate security schemes
RSpec::OpenAPI.security_schemes = {
  'MyToken' => {
    description: 'Authenticate API requests via a JWT',
    type: 'http',
    scheme: 'bearer',
    bearerFormat: 'JWT',
  },
}

# Generate a comment on top of a schema file
RSpec::OpenAPI.comment = <<~EOS
  This file is auto-generated by rspec-openapi https://github.com/k0kubun/rspec-openapi

  When you write a spec in spec/requests, running the spec with `OPENAPI=1 rspec` will
  update this file automatically. You can also manually edit this file.
EOS

# Generate a custom description, given an RSpec example
RSpec::OpenAPI.description_builder = -> (example) { example.description }

# Generate a custom summary, given an RSpec example
# This example uses the summary from the example_group.
RSpec::OpenAPI.summary_builder = ->(example) { example.metadata.dig(:example_group, :openapi, :summary) }

# Generate a custom tags, given an RSpec example
# This example uses the tags from the parent_example_group
RSpec::OpenAPI.tags_builder = -> (example) { example.metadata.dig(:example_group, :parent_example_group, :openapi, :tags) }

# Configure custom format for specific properties
# This example assigns 'date-time' format to properties with names ending in '_at'
RSpec::OpenAPI.formats_builder = ->(_example, key) { key.end_with?('_at') ? 'date-time' : nil }

# Change the example type(s) that will generate schema
RSpec::OpenAPI.example_types = %i[request]

# Configure which path params to ignore
# :controller and :action always exist. :format is added when routes is configured as such.
RSpec::OpenAPI.ignored_path_params = %i[controller action format]

# Configure which paths to ignore.
# You can exclude some specs via `openapi: false`.
# But, in a complex API usage scenario, you may need to include spec itself, but exclude some private paths.
# In that case, you can specify the paths to ignore.
# String or Regexp is acceptable.
RSpec::OpenAPI.ignored_paths = ["/admin/full/path/", Regexp.new("^/_internal/")]

# Your custom post-processing hook (like unrandomizing IDs)
RSpec::OpenAPI.post_process_hook = -> (path, records, spec) do
  RSpec::OpenAPI::HashHelper.matched_paths(spec, 'paths.*.*.responses.*.content.*.*.*.id').each do |paths|
    spec.dig(*paths[0..-2]).merge!(id: '123')
  end
end

Can I use rspec-openapi with $ref to minimize duplication of schema?

Yes, rspec-openapi v0.7.0+ supports $ref mechanism and generates schemas under #/components/schemas with some manual steps.

  1. First, generate plain OpenAPI file.
  2. Then, manually replace the duplications with $ref.
paths:
  "/users":
    get:
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/User"
  "/users/{id}":
    get:
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
# Note) #/components/schemas is not needed to be defined.
  1. Then, re-run rspec-openapi. It will generate #/components/schemas with the referenced schema (User for example) newly-generated or updated.
paths:
  "/users":
    get:
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/User"
  "/users/{id}":
    get:
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        role:
          type: array
          items:
            type: string

rspec-openapi also supports $ref in properties of schemas. Example)

paths:
  "/locations":
    get:
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Location"
components:
  schemas:
    Location:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        Coordinate:
          "$ref": "#/components/schemas/Coordinate"
    Coordinate:
      type: object
      properties:
        lat:
          type: string
        lon:
          type: string

Note that automatic schemas update feature is still new and may not work in complex scenario. If you find a room for improvement, open an issue.

How can I add information which can't be generated from RSpec?

rspec-openapi tries to preserve manual modifications as much as possible when generating specs. You can directly edit doc/openapi.yaml as you like without spoiling the automatic generation capability.

Can I exclude specific specs from OpenAPI generation?

Yes, you can specify openapi: false to disable the automatic generation.

RSpec.describe '/resources', type: :request, openapi: false do
  # ...
end

# or

RSpec.describe '/resources', type: :request do
  it 'returns a resource', openapi: false do
    # ...
  end
end

Customizations

Some examples' attributes can be overwritten via RSpec metadata options. Example:

  describe 'GET /api/v1/posts', openapi: {
  summary: 'list all posts',
  description: 'list all posts ordered by pub_date',
  tags: %w[v1 posts],
  required_request_params: %w[limit],
  security: [{ "MyToken" => [] }],
} do
  # ...
end

NOTE: description key will override also the one provided by RSpec::OpenAPI.description_builder method.

Enum Support

You can specify enum values for string properties that should have a fixed set of allowed values. Since enums cannot be reliably inferred from test data, you can define them via the enum metadata option:

it 'returns user status', openapi: {
  enum: {
    'status' => %w[active inactive suspended],
  },
} do
  get '/users/1'
  expect(response.status).to eq(200)
end

This generates:

schema:
  type: object
  properties:
    status:
      type: string
      enum:
        - active
        - inactive
        - suspended

Nested Paths

For nested objects, use dot notation to specify the path:

it 'returns user with role', openapi: {
  enum: {
    'status' => %w[active inactive],
    'user.role' => %w[admin user guest],
  },
} do
  get '/teams/1'
  # Response: { "status": "active", "user": { "name": "John", "role": "admin" } }
  expect(response.status).to eq(200)
end

Array Items

For properties inside array items, use the array property name followed by the item property:

it 'returns items with status', openapi: {
  enum: {
    'items.status' => %w[pending completed failed],
    'items.priority' => %w[high medium low],
  },
} do
  get '/tasks'
  # Response: { "items": [{ "id": 1, "status": "pending", "priority": "high" }] }
  expect(response.status).to eq(200)
end

Request vs Response Enums

By default, enum applies to both request and response bodies. If you need different enum values for request and response, use request_enum and response_enum:

it 'creates a task', openapi: {
  request_enum: {
    'action' => %w[create update delete],
  },
  response_enum: {
    'status' => %w[pending processing completed],
  },
} do
  post '/tasks', params: { action: 'create', name: 'New Task' }
  expect(response.status).to eq(201)
end

Dynamic-key Object Support (additionalProperties)

When an endpoint returns (or accepts) an object whose keys are not part of its schema — for example a permissions map { "can_edit": true, "can_delete": false } where new permissions can appear without an API change — rspec-openapi would otherwise capture the test response literally and emit each observed key as a fixed property. You can override this with the additional_properties metadata, which replaces the captured properties / required of the matched object with additionalProperties.

Keys are dot-notation paths (the same scheme as enum); the empty string '' targets the body root.

it 'returns a permission map', openapi: {
  additional_properties: { 'data' => { type: 'boolean' } },
} do
  get '/api/v1/permissions'
  # Response: { "data": { "can_edit": true, "can_delete": false } }
  expect(response.status).to eq(200)
end

This generates:

schema:
  type: object
  properties:
    data:
      type: object
      additionalProperties:
        type: boolean
  required:
    - data

Root-level dynamic keys

When the entire body is a dynamic dict, use '' as the path:

it 'lists organisation memberships', openapi: {
  additional_properties: { '' => { type: 'boolean' } },
} do
  get '/api/v1/organisations/acme/check_memberships'
  # Response: { "user-hash-a": true, "user-hash-b": false }
  expect(response.status).to eq(200)
end
schema:
  type: object
  additionalProperties:
    type: boolean

Schemas referenced via $ref

The value of additionalProperties can be any schema, including a $ref:

it 'returns tags by id', openapi: {
  additional_properties: { '' => { '$ref' => '#/components/schemas/Tag' } },
} do
  get '/tags'
  expect(response.status).to eq(200)
end
schema:
  type: object
  additionalProperties:
    $ref: '#/components/schemas/Tag'

Request vs Response

additional_properties applies to both request and response by default. Use request_additional_properties / response_additional_properties to scope to one side:

it 'records arbitrary metrics', openapi: {
  request_additional_properties: { '' => { type: 'integer' } },
} do
  post '/metrics', params: { 'page_views' => 100, 'signups' => 5 }
  expect(response.status).to eq(201)
end

Forbidding extras with false (or true)

Attaching additionalProperties: false forbid dynamic keys while : true (default) explicitly allow those. This is useful mostly for the false case, to prevent unexpected extras:

it 'returns a closed object', openapi: {
  additional_properties: { '' => false },
} do
  get '/api/v1/profile'
  # Response: { "id": 1, "name": "alice" }
  expect(response.status).to eq(200)
end
schema:
  type: object
  properties:
    id: { type: integer }
    name: { type: string }
  required: [ id, name ]
  additionalProperties: false

Hybrid objects (known keys + dynamic keys)

When an object has both a fixed shape and dynamic keys, use hybrid_additional_properties (request_hybrid_additional_properties and response_hybrid_additional_properties to scope to one side). Captured properties stay; the supplied schema is attached as additionalProperties.

At the root (whole body is the hybrid object):

it 'returns an item with custom attributes', openapi: {
  hybrid_additional_properties: { '' => { type: 'string' } },
} do
  get '/api/v1/items/42'
  # Response: { "id": 42, "attr_color": "red", "attr_size": "large" }
  expect(response.status).to eq(200)
end
schema:
  type: object
  properties:
    id: { type: integer }
    attr_color: { type: string }
    attr_size: { type: string }
  required: [ id, attr_color, attr_size ]
  additionalProperties:
    type: string

Notes on behavior

  • A hash schema in additional_properties fully replaces the captured properties / required at the matched node. To keep observed properties alongside additionalProperties, use hybrid_additional_properties or pass a boolean.
  • Recursion stops once additional_properties matches a path with a hash schema: nested overrides underneath (e.g. setting both 'data' and 'data.meta') won't be applied, because the supplied dictionary value schema describes every child uniformly and isn't traversed further. hybrid_additional_properties keeps observed properties so children are still walked normally.
  • When migrating an existing schema (one previously generated with concrete properties / required) by adding this metadata, the next regeneration prunes the stale properties / required automatically. If you had manually added additionalProperties to an object that also has properties, that hybrid shape is preserved.

Multiple Examples Mode

You can generate multiple named examples for the same endpoint using example_mode:

describe '#index', openapi: { example_mode: :multiple } do
  it 'with pagination' do
    get '/tables', params: { page: 1, per: 10 }
    expect(response.status).to eq(200)
  end

  it 'with filter' do
    get '/tables', params: { filter: { name: 'test' } }
    expect(response.status).to eq(200)
  end
end

This generates OpenAPI with multiple named examples:

responses:
  '200':
    content:
      application/json:
        schema: { ... }
        examples:
          with_pagination:
            value: { ... }
          with_filter:
            value: { ... }

Available example_mode values:

  • :single (default) - generates single example field
  • :multiple - generates named examples with test descriptions as keys
  • :none - generates only schema, no examples

example_mode also accepts a hash form so you can configure the request body and the response independently:

describe 'POST /sign_in', openapi: { example_mode: { request: :multiple, response: :multiple } } do
  ...
end

Missing keys default to :single, so { example_mode: { request: :multiple } } keeps the response on :single.

The bare symbol form maps as follows:

example_mode value request side response side
:single (default) :single :single
:none :none :none
:multiple :single ⚠️ :multiple
{ request: :multiple, response: :multiple } :multiple :multiple

⚠️ The bare :multiple shorthand is currently response-only to preserve the behavior that existed before request-side multi-examples were introduced. A future major release will change it to mean { request: :multiple, response: :multiple }.

The mode is inherited by nested contexts and can be overridden at any level.

Note: If multiple examples resolve to the same example key for a single endpoint, the last one wins (overwrites).

Request Body Multiple Examples

With example_mode: { request: :multiple }, the generator produces named examples: for requestBody. This is useful when the same endpoint accepts mutually exclusive request shapes (e.g. OTP vs email/password sign-in), where merging them into a single example: would produce a nonsensical mixed payload.

describe 'POST /sign_in',
         openapi: { example_mode: { request: :multiple, response: :multiple } } do
  it 'with otp' do
    post '/sign_in',
         params: { auth_type: 'otp', otp: '123456' }.to_json,
         headers: { 'CONTENT_TYPE' => 'application/json' }
    expect(response.status).to eq(200)
  end

  it 'with email password' do
    post '/sign_in',
         params: { auth_type: 'email', email: 'a@b.c', password: 'pw' }.to_json,
         headers: { 'CONTENT_TYPE' => 'application/json' }
    expect(response.status).to eq(200)
  end
end

The generated requestBody keeps each shape as its own keyed example:

requestBody:
  content:
    application/json:
      schema:
        type: object
        properties:
          auth_type: { type: string }
          otp: { type: string }
          email: { type: string }
          password: { type: string }
        required:
          - auth_type
      examples:
        with_otp:
          summary: with otp
          value: { auth_type: otp, otp: '123456' }
        with_email_password:
          summary: with email password
          value: { auth_type: email, email: a@b.c, password: pw }

The keys default to the test's description (normalized to lowercase + underscores). Override with openapi: { example_key: 'custom_key', example_name: 'Custom Summary' }, just like for response examples.

Capturing 4xx requestBody examples

When request_example_mode is :single (the default), tests with status >= 400 are intentionally skipped for requestBody so that error payloads don't pollute the request schema. When request: :multiple is set, this short-circuit is lifted: each failing test contributes its own keyed example, so you can document validation-failure shapes alongside success shapes:

describe 'POST /sign_in',
         openapi: { example_mode: { request: :multiple, response: :multiple } } do
  it 'with otp' do
    post '/sign_in', params: { auth_type: 'otp', otp: '123456' }.to_json,
         headers: { 'CONTENT_TYPE' => 'application/json' }
    expect(response.status).to eq(200)
  end

  it 'with missing fields' do
    post '/sign_in', params: { auth_type: 'email' }.to_json,
         headers: { 'CONTENT_TYPE' => 'application/json' }
    expect(response.status).to eq(400)
  end
end
requestBody:
  content:
    application/json:
      examples:
        with_otp: { summary: with otp, value: { ... } }
        with_missing_fields: { summary: with missing fields, value: { ... } }
responses:
  '200': { ... }
  '400': { ... }

Mixing :single (some tests) with :multiple (others) on the same endpoint also works for requestBody - the merger up-converts the singular example: into the examples: map automatically (see Merge Behavior with Mixed Modes below).

Merge Behavior with Mixed Modes

When multiple tests target the same endpoint with different example_mode settings (even from different spec files), the merger automatically converts to examples format:

# spec/requests/api_spec.rb
describe 'GET /users' do
  it 'returns users' do
    # default :single mode
    get '/users'
    expect(response.status).to eq(200)
  end
end

# spec/requests/admin_spec.rb
describe 'GET /users', openapi: { example_mode: :multiple } do
  it 'with admin privileges' do
    get '/users', headers: { 'X-Admin': 'true' }
    expect(response.status).to eq(200)
  end
end

Result - both examples merged into examples:

responses:
  '200':
    content:
      application/json:
        examples:
          returns_users:
            value: { ... }
          with_admin_privileges:
            value: { ... }

To exclude specific tests from example generation, use example_mode: :none:

describe 'GET /users', openapi: { example_mode: :none } do
  it 'edge case test' do
    # This won't add examples to OpenAPI spec
  end
end

Experimental minitest support

Even if you are not using rspec this gem might help you with its experimental support for minitest.

Example:


class TablesTest < ActionDispatch::IntegrationTest
  openapi!

  test "GET /index returns a list of tables" do
    get '/tables', params: { page: '1', per: '10' }, headers: { authorization: 'k0kubun' }
    assert_response :success
  end

  test "GET /index does not return tables if unauthorized" do
    get '/tables'
    assert_response :unauthorized
  end

  # ...
end

It should work with both classes inheriting from ActionDispatch::IntegrationTest and with classes using Rack::Test directly, as long as you call openapi! in your test class.

Please note that not all features present in the rspec integration work with minitest (yet). For example, custom per test case metadata is not supported. A custom description_builder will not work either.

Run minitest with OPENAPI=1 to generate doc/openapi.yaml for your request specs.

$ OPENAPI=1 bundle exec rails t

Existing RSpec plugins which have OpenAPI integration:

Acknowledgements

Releasing

  1. Ensure RubyGems trusted publishing is configured for this repo and gem ownership ( see Trusted publishing).
  2. In GitHub Actions, run the prepare release workflow manually. It bumps lib/rspec/openapi/version.rb, pushes release/v<version> to origin, and opens a PR.
  3. Review and merge the release PR into the default branch.
  4. Create and push a tag v<version> on the merged commit (via the GitHub UI or git tag v<version>; git push origin v<version>). Tag creation triggers the Publish to RubyGems workflow, which publishes the gem and creates the GitHub release notes automatically.

License

The gem is available as open source under the terms of the MIT License.