rspec-openapi

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.

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.
- First, generate plain OpenAPI file.
- 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.
- Then, re-run rspec-openapi. It will generate
#/components/schemaswith the referenced schema (Userfor 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_propertiesfully replaces the capturedproperties/requiredat the matched node. To keep observed properties alongsideadditionalProperties, usehybrid_additional_propertiesor pass a boolean. - Recursion stops once
additional_propertiesmatches 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_propertieskeeps 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 staleproperties/requiredautomatically. If you had manually addedadditionalPropertiesto an object that also hasproperties, 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 singleexamplefield:multiple- generates namedexampleswith 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
Links
Existing RSpec plugins which have OpenAPI integration:
Acknowledgements
- Heavily inspired by r7kamura/autodoc
- Orignally created by k0kubun and the ownership was transferred to exoego in 2022-11-29.
Releasing
- Ensure RubyGems trusted publishing is configured for this repo and gem ownership ( see Trusted publishing).
- In GitHub Actions, run the
prepare releaseworkflow manually. It bumpslib/rspec/openapi/version.rb, pushesrelease/v<version>to origin, and opens a PR. - Review and merge the release PR into the default branch.
- Create and push a tag
v<version>on the merged commit (via the GitHub UI orgit tag v<version>; git push origin v<version>). Tag creation triggers thePublish to RubyGemsworkflow, 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.