base on Generate OpenAPI schema from RSpec request specs # rspec-openapi [![Gem Version](https://badge.fury.io/rb/rspec-openapi.svg)](https://rubygems.org/gems/rspec-openapi) [![test](https://github.com/exoego/rspec-openapi/actions/workflows/test.yml/badge.svg)](https://github.com/exoego/rspec-openapi/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/exoego/rspec-openapi/branch/master/graph/badge.svg?token=egYm6AlxkD)](https://codecov.io/gh/exoego/rspec-openapi) [![Ruby-toolbox](https://img.shields.io/badge/ruby-toolbox-a61414?cacheSeconds=31536000)](https://www.ruby-toolbox.com/projects/rspec-openapi) [![DeepWiki](https://img.shields.io/badge/See_on-DeepWiki-blue)](https://deepwiki.com/exoego/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](#links), 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: ```ruby gem 'rspec-openapi', group: :test ``` ## Usage Run rspec with OPENAPI=1 to generate `doc/openapi.yaml` for your request specs. ```bash $ OPENAPI=1 bundle exec rspec ``` ### Example Let's say you have [a request spec](https://github.com/exoego/rspec-openapi/blob/24e5c567c2e90945c7a41f19f71634ac028cc314/spec/requests/rails_spec.rb#L38) like this: ```rb 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](./spec/rails/doc/openapi.yaml) like: ```yml 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](https://github.com/swagger-api/swagger-ui) or [Redoc](https://github.com/Redocly/redoc). ![Redoc example](./spec/apps/rails/doc/screenshot.png) ### Configuration The following configurations are optional. ```rb 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](https://swagger.io/docs/specification/using-ref/) and generates schemas under `#/components/schemas` with some manual steps. 1. First, generate plain OpenAPI file. 2. Then, manually replace the duplications with `$ref`. ```yaml 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. ``` 3. Then, re-run rspec-openapi. It will generate `#/components/schemas` with the referenced schema (`User` for example) newly-generated or updated. ```yaml 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) ```yaml 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. ```rb 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: ```rb 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. ### Multiple Examples Mode You can generate multiple named examples for the same endpoint using `example_mode`: ```rb 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: ```yaml 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 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). #### 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: ```rb # 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`: ```yaml responses: '200': content: application/json: examples: returns_users: value: { ... } with_admin_privileges: value: { ... } ``` To exclude specific tests from example generation, use `example_mode: :none`: ```rb 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: ```rb 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. ```bash $ OPENAPI=1 bundle exec rails t ``` ## Links Existing RSpec plugins which have OpenAPI integration: * [zipmark/rspec\_api\_documentation](https://github.com/zipmark/rspec_api_documentation) * [rswag/rswag](https://github.com/rswag/rswag) * [drewish/rspec-rails-swagger](https://github.com/drewish/rspec-rails-swagger) ## Acknowledgements * Heavily inspired by [r7kamura/autodoc](https://github.com/r7kamura/autodoc) * Orignally created by [k0kubun](https://github.com/k0kubun) and the ownership was transferred to [exoego](https://github.com/exoego) in 2022-11-29. ## Releasing 1. Ensure RubyGems trusted publishing is configured for this repo and gem ownership (see [Trusted publishing](https://guides.rubygems.org/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](https://opensource.org/licenses/MIT). ", Assign "at most 3 tags" to the expected json: {"id":"9549","tags":[]} "only from the tags list I provide: [{"id":77,"name":"3d"},{"id":89,"name":"agent"},{"id":17,"name":"ai"},{"id":54,"name":"algorithm"},{"id":24,"name":"api"},{"id":44,"name":"authentication"},{"id":3,"name":"aws"},{"id":27,"name":"backend"},{"id":60,"name":"benchmark"},{"id":72,"name":"best-practices"},{"id":39,"name":"bitcoin"},{"id":37,"name":"blockchain"},{"id":1,"name":"blog"},{"id":45,"name":"bundler"},{"id":58,"name":"cache"},{"id":21,"name":"chat"},{"id":49,"name":"cicd"},{"id":4,"name":"cli"},{"id":64,"name":"cloud-native"},{"id":48,"name":"cms"},{"id":61,"name":"compiler"},{"id":68,"name":"containerization"},{"id":92,"name":"crm"},{"id":34,"name":"data"},{"id":47,"name":"database"},{"id":8,"name":"declarative-gui "},{"id":9,"name":"deploy-tool"},{"id":53,"name":"desktop-app"},{"id":6,"name":"dev-exp-lib"},{"id":59,"name":"dev-tool"},{"id":13,"name":"ecommerce"},{"id":26,"name":"editor"},{"id":66,"name":"emulator"},{"id":62,"name":"filesystem"},{"id":80,"name":"finance"},{"id":15,"name":"firmware"},{"id":73,"name":"for-fun"},{"id":2,"name":"framework"},{"id":11,"name":"frontend"},{"id":22,"name":"game"},{"id":81,"name":"game-engine "},{"id":23,"name":"graphql"},{"id":84,"name":"gui"},{"id":91,"name":"http"},{"id":5,"name":"http-client"},{"id":51,"name":"iac"},{"id":30,"name":"ide"},{"id":78,"name":"iot"},{"id":40,"name":"json"},{"id":83,"name":"julian"},{"id":38,"name":"k8s"},{"id":31,"name":"language"},{"id":10,"name":"learning-resource"},{"id":33,"name":"lib"},{"id":41,"name":"linter"},{"id":28,"name":"lms"},{"id":16,"name":"logging"},{"id":76,"name":"low-code"},{"id":90,"name":"message-queue"},{"id":42,"name":"mobile-app"},{"id":18,"name":"monitoring"},{"id":36,"name":"networking"},{"id":7,"name":"node-version"},{"id":55,"name":"nosql"},{"id":57,"name":"observability"},{"id":46,"name":"orm"},{"id":52,"name":"os"},{"id":14,"name":"parser"},{"id":74,"name":"react"},{"id":82,"name":"real-time"},{"id":56,"name":"robot"},{"id":65,"name":"runtime"},{"id":32,"name":"sdk"},{"id":71,"name":"search"},{"id":63,"name":"secrets"},{"id":25,"name":"security"},{"id":85,"name":"server"},{"id":86,"name":"serverless"},{"id":70,"name":"storage"},{"id":75,"name":"system-design"},{"id":79,"name":"terminal"},{"id":29,"name":"testing"},{"id":12,"name":"ui"},{"id":50,"name":"ux"},{"id":88,"name":"video"},{"id":20,"name":"web-app"},{"id":35,"name":"web-server"},{"id":43,"name":"webassembly"},{"id":69,"name":"workflow"},{"id":87,"name":"yaml"}]" returns me the "expected json"