rspec-openapi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9534432fc4ab2f67ce016ed07e0c36c687c201e4c1c01388beb4d845a24def6d
4
+ data.tar.gz: e7884e1ea34519588d2c008af56faaf7c7435f5c287ec5408515a6b66d16cb7c
5
+ SHA512:
6
+ metadata.gz: 50af774aa04fc4fc47a52b89ce4355b313faa5d201796aff05f30fdb275d411b6f1bfcbb7762f3a6363484cec9ab69067c70dba1fc867f163631061d0629c874
7
+ data.tar.gz: 7498b91af6496b0d4a500405c194584425ca32276f6c9fedbbe92f431c370a035210f99ec103a33d595dc4f18cc2de361cea98f327a4324d3ebbd23a3bcfbb54
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /Gemfile.lock
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,6 @@
1
+ ---
2
+ language: ruby
3
+ cache: bundler
4
+ rvm:
5
+ - 2.7.1
6
+ before_install: gem install bundler -v 2.1.4
@@ -0,0 +1,3 @@
1
+ ## v0.1.0
2
+
3
+ * Initial release
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rspec-openapi.gemspec
4
+ gemspec
5
+
6
+ gem 'rails', '6.0.3.2'
7
+ gem 'rspec-rails'
8
+ gem 'pry'
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Takashi Kokubun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,169 @@
1
+ # rspec-openapi
2
+
3
+ Generate OpenAPI specs from RSpec request specs.
4
+
5
+ ## What's this?
6
+
7
+ There are some gems which generate OpenAPI specs from RSpec request specs.
8
+ However, they require a special DSL specific to these gems, and we can't reuse existing request specs as they are.
9
+
10
+ Unlike such [existing gems](#links), rspec-openapi can generate OpenAPI specs from request specs without requiring any special DSL.
11
+ Furthermore, rspec-openapi keeps manual modifications when it merges automated changes to OpenAPI specs
12
+ in case we can't generate everything from request specs.
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'rspec-openapi', group: :test
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Run rspec with OPENAPI=1 to generate `doc/openapi.yaml` for your request specs.
25
+
26
+ ```bash
27
+ $ OPENAPI=1 rspec
28
+ ```
29
+
30
+ ### Example
31
+
32
+ Let's say you have [a request spec](./spec/requests/table_spec.rb) like this:
33
+
34
+ ```rb
35
+ RSpec.describe 'Tables', type: :request do
36
+ describe '#index' do
37
+ it 'returns a list of tables' do
38
+ get '/tables', params: { page: '1', per: '10' }, headers: { authorization: 'k0kubun' }
39
+ expect(response.status).to eq(200)
40
+ end
41
+
42
+ it 'does not return tables if unauthorized' do
43
+ get '/tables'
44
+ expect(response.status).to eq(401)
45
+ end
46
+ end
47
+
48
+ # ...
49
+ end
50
+ ```
51
+
52
+ If you run the spec with `OPENAPI=1`,
53
+
54
+ ```
55
+ OPENAPI=1 be rspec spec/requests/tables_spec.rb
56
+ ```
57
+
58
+ It will generate [`doc/openapi.yaml` file](./spec/railsapp/doc/openapi.yaml) like:
59
+
60
+ ```yml
61
+ openapi: 3.0.3
62
+ info:
63
+ title: rspec-openapi
64
+ paths:
65
+ "/tables":
66
+ get:
67
+ summary: tables#index
68
+ parameters:
69
+ - name: page
70
+ in: query
71
+ schema:
72
+ type: integer
73
+ - name: per
74
+ in: query
75
+ schema:
76
+ type: integer
77
+ responses:
78
+ '200':
79
+ description: returns a list of tables
80
+ content:
81
+ application/json:
82
+ schema:
83
+ type: array
84
+ items:
85
+ type: object
86
+ properties:
87
+ id:
88
+ type: integer
89
+ name:
90
+ type: string
91
+ # ...
92
+ ```
93
+
94
+ 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).
95
+
96
+ ![Redoc example](./spec/railsapp/doc/screenshot.png)
97
+
98
+
99
+ ### Configuration
100
+
101
+ If you want to change the path to generate a spec from `doc/openapi.yaml`, use:
102
+
103
+ ```rb
104
+ RSpec::OpenAPI.path = 'doc/schema.yaml'
105
+ ```
106
+
107
+ ### How can I add information which can't be generated from RSpec?
108
+
109
+ rspec-openapi tries to keep manual modifications as much as possible when generating specs.
110
+ You can directly edit `doc/openapi.yaml` as you like without spoiling the automatic generation capability.
111
+
112
+ ### Can I exclude specific specs from OpenAPI generation?
113
+
114
+ Yes, you can specify `openapi: false` to disable the automatic generation.
115
+
116
+ ```rb
117
+ RSpec.describe '/resources', type: :request, openapi: false do
118
+ # ...
119
+ end
120
+
121
+ # or
122
+
123
+ RSpec.describe '/resources', type: :request do
124
+ it 'returns a resource', openapi: false do
125
+ # ...
126
+ end
127
+ end
128
+ ```
129
+
130
+ ## Project status
131
+
132
+ PoC / Experimental
133
+
134
+ This worked for some of my Rails apps, but this may raise a basic error for your app.
135
+
136
+ ### Current limitations
137
+
138
+ * Generating a JSON file is not supported yet
139
+ * This only works for RSpec request specs
140
+ * Only Rails is supported for looking up a request route
141
+
142
+ ### Other missing features with notes
143
+
144
+ * Delete obsoleted endpoints
145
+ * Give up, or at least make the feature optional?
146
+ * Running all to detect obsoleted endpoints is sometimes not realistic anyway.
147
+ * Intelligent merges
148
+ * To maintain both automated changes and manual edits, the schema merge needs to be intelligent.
149
+ * We'll just deep-reverse-merge schema for now, but if there's a $ref for example, modifications
150
+ there should be rerouted to the referenced object.
151
+ * A type could be an array of all possible types when merged.
152
+
153
+ ## Links
154
+
155
+ Existing RSpec plugins which have OpenAPI integration:
156
+
157
+ * [zipmark/rspec\_api\_documentation](https://github.com/zipmark/rspec_api_documentation)
158
+ * [rswag/rswag](https://github.com/rswag/rswag)
159
+ * [drewish/rspec-rails-swagger](https://github.com/drewish/rspec-rails-swagger)
160
+
161
+ ## Acknowledgements
162
+
163
+ This gem was heavily inspired by the following gem:
164
+
165
+ * [r7kamura/autodoc](https://github.com/r7kamura/autodoc)
166
+
167
+ ## License
168
+
169
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "rspec/openapi"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,10 @@
1
+ require 'rspec/openapi/version'
2
+ require 'rspec/openapi/hooks' if ENV['OPENAPI']
3
+
4
+ module RSpec::OpenAPI
5
+ @path = 'doc/openapi.yaml'
6
+
7
+ class << self
8
+ attr_accessor :path
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ class << RSpec::OpenAPI::DefaultSchema = Object.new
2
+ def build(title)
3
+ {
4
+ openapi: '3.0.3',
5
+ info: {
6
+ title: title,
7
+ },
8
+ paths: {},
9
+ }.freeze
10
+ end
11
+ end
@@ -0,0 +1,24 @@
1
+ require 'rspec'
2
+ require 'rspec/openapi/default_schema'
3
+ require 'rspec/openapi/record_builder'
4
+ require 'rspec/openapi/schema_builder'
5
+ require 'rspec/openapi/schema_file'
6
+ require 'rspec/openapi/schema_merger'
7
+
8
+ records = []
9
+
10
+ RSpec.configuration.after(:each) do |example|
11
+ if example.metadata[:type] == :request && example.metadata[:openapi] != false && request && response
12
+ records << RSpec::OpenAPI::RecordBuilder.build(self, example: example)
13
+ end
14
+ end
15
+
16
+ RSpec.configuration.after(:suite) do
17
+ title = File.basename(Dir.pwd)
18
+ RSpec::OpenAPI::SchemaFile.new(RSpec::OpenAPI.path).edit do |spec|
19
+ RSpec::OpenAPI::SchemaMerger.reverse_merge!(spec, RSpec::OpenAPI::DefaultSchema.build(title))
20
+ records.each do |record|
21
+ RSpec::OpenAPI::SchemaMerger.reverse_merge!(spec, RSpec::OpenAPI::SchemaBuilder.build(record))
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ RSpec::OpenAPI::Record = Struct.new(
2
+ :method, # @param [String] - "GET"
3
+ :path, # @param [String] - "/v1/status/:id"
4
+ :path_params, # @param [Hash] - {:controller=>"v1/statuses", :action=>"create", :id=>"1"}
5
+ :query_params, # @param [Hash] - {:query=>"string"}
6
+ :request_params, # @param [Hash] - {:request=>"body"}
7
+ :request_content_type, # @param [String] - "application/json"
8
+ :controller, # @param [String] - "v1/statuses"
9
+ :action, # @param [String] - "show"
10
+ :description, # @param [String] - "returns a status"
11
+ :status, # @param [Integer] - 200
12
+ :response_body, # @param [Object] - {"status" => "ok"}
13
+ :response_content_type, # @param [String] - "application/json"
14
+ keyword_init: true,
15
+ )
@@ -0,0 +1,38 @@
1
+ require 'rspec/openapi/record'
2
+
3
+ class << RSpec::OpenAPI::RecordBuilder = Object.new
4
+ # @param [RSpec::ExampleGroups::*] context
5
+ # @param [RSpec::Core::Example] example
6
+ # @return [RSpec::OpenAPI::Record]
7
+ def build(context, example:)
8
+ # TODO: Support Non-Rails frameworks
9
+ request = context.request
10
+ response = context.response
11
+ route = find_route(request)
12
+
13
+ RSpec::OpenAPI::Record.new(
14
+ method: request.request_method,
15
+ path: route.path.spec.to_s.delete_suffix('(.:format)'),
16
+ path_params: request.path_parameters,
17
+ query_params: request.query_parameters,
18
+ request_params: request.request_parameters,
19
+ request_content_type: request.content_type,
20
+ controller: route.requirements[:controller],
21
+ action: route.requirements[:action],
22
+ description: example.description,
23
+ status: response.status,
24
+ response_body: response.parsed_body,
25
+ response_content_type: response.content_type,
26
+ ).freeze
27
+ end
28
+
29
+ private
30
+
31
+ # @param [ActionDispatch::Request] request
32
+ def find_route(request)
33
+ Rails.application.routes.router.recognize(request) do |route|
34
+ return route
35
+ end
36
+ raise "No route matched for #{request.request_method} #{request.path_info}"
37
+ end
38
+ end
@@ -0,0 +1,113 @@
1
+ class << RSpec::OpenAPI::SchemaBuilder = Object.new
2
+ # @param [RSpec::OpenAPI::Record] record
3
+ # @return [Hash]
4
+ def build(record)
5
+ {
6
+ paths: {
7
+ record.path => {
8
+ record.method.downcase => {
9
+ summary: "#{record.controller}##{record.action}",
10
+ parameters: build_parameters(record),
11
+ requestBody: build_request_body(record),
12
+ responses: {
13
+ record.status.to_s => {
14
+ description: record.description,
15
+ content: {
16
+ normalize_content_type(record.response_content_type) => {
17
+ schema: build_property(record.response_body),
18
+ },
19
+ },
20
+ },
21
+ },
22
+ }.compact,
23
+ },
24
+ },
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def build_parameters(record)
31
+ parameters = []
32
+
33
+ record.path_params.each do |key, value|
34
+ next if %i[controller action].include?(key)
35
+ parameters << {
36
+ name: key.to_s,
37
+ in: 'path',
38
+ schema: build_property(try_cast(value)),
39
+ }
40
+ end
41
+
42
+ record.query_params.each do |key, value|
43
+ parameters << {
44
+ name: key.to_s,
45
+ in: 'query',
46
+ schema: build_property(try_cast(value)),
47
+ }
48
+ end
49
+
50
+ return nil if parameters.empty?
51
+ parameters
52
+ end
53
+
54
+ def build_request_body(record)
55
+ return nil if record.request_content_type.nil?
56
+ return nil if record.request_params.empty?
57
+
58
+ {
59
+ content: {
60
+ normalize_content_type(record.request_content_type) => {
61
+ schema: build_property(record.request_params),
62
+ }
63
+ }
64
+ }
65
+ end
66
+
67
+ def build_property(value)
68
+ property = { type: build_type(value) }
69
+ case value
70
+ when Array
71
+ property[:items] = build_property(value.first)
72
+ when Hash
73
+ property[:properties] = {}.tap do |properties|
74
+ value.each do |key, v|
75
+ properties[key] = build_property(v)
76
+ end
77
+ end
78
+ end
79
+ property
80
+ end
81
+
82
+ def build_type(value)
83
+ case value
84
+ when String
85
+ 'string'
86
+ when Integer
87
+ 'integer'
88
+ when TrueClass, FalseClass
89
+ 'boolean'
90
+ when Array
91
+ 'array'
92
+ when Hash
93
+ 'object'
94
+ when NilClass
95
+ 'null'
96
+ else
97
+ raise NotImplementedError, "type detection is not implemented for: #{value.inspect}"
98
+ end
99
+ end
100
+
101
+ # Convert an always-String param to an appropriate type
102
+ def try_cast(value)
103
+ begin
104
+ Integer(value)
105
+ rescue TypeError, ArgumentError
106
+ value
107
+ end
108
+ end
109
+
110
+ def normalize_content_type(content_type)
111
+ content_type.sub(/;.+\z/, '')
112
+ end
113
+ end
@@ -0,0 +1,31 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ # TODO: Support JSON
5
+ class RSpec::OpenAPI::SchemaFile
6
+ # @param [String] path
7
+ def initialize(path)
8
+ @path = path
9
+ end
10
+
11
+ def edit(&block)
12
+ spec = read
13
+ block.call(spec)
14
+ ensure
15
+ write(spec)
16
+ end
17
+
18
+ private
19
+
20
+ # @return [Hash]
21
+ def read
22
+ return {} unless File.exist?(@path)
23
+ YAML.load(File.read(@path))
24
+ end
25
+
26
+ # @param [Hash] spec
27
+ def write(spec)
28
+ FileUtils.mkdir_p(File.dirname(@path))
29
+ File.write(@path, YAML.dump(spec))
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ class << RSpec::OpenAPI::SchemaMerger = Object.new
2
+ # @param [Hash] base
3
+ # @param [Hash] spec
4
+ def reverse_merge!(base, spec)
5
+ spec = normalize_keys(spec)
6
+ deep_reverse_merge!(base, spec)
7
+ end
8
+
9
+ private
10
+
11
+ def normalize_keys(spec)
12
+ case spec
13
+ when Hash
14
+ spec.map do |key, value|
15
+ [key.to_s, normalize_keys(value)]
16
+ end.to_h
17
+ when Array
18
+ spec.map { |s| normalize_keys(s) }
19
+ else
20
+ spec
21
+ end
22
+ end
23
+
24
+ # Not doing `base.replace(deep_merge(base, spec))` to preserve key orders
25
+ # TODO: Perform more intelligent merges like rerouting edits / merging types
26
+ # Should we probably force-merge `summary` regardless of manual modifications?
27
+ def deep_reverse_merge!(base, spec)
28
+ spec.each do |key, value|
29
+ if base[key].is_a?(Hash) && value.is_a?(Hash)
30
+ deep_reverse_merge!(base[key], value)
31
+ elsif !base.key?(key)
32
+ base[key] = value
33
+ end
34
+ end
35
+ base
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ module RSpec
2
+ module OpenAPI
3
+ VERSION = '0.1.0'
4
+ end
5
+ end
@@ -0,0 +1,27 @@
1
+ require_relative 'lib/rspec/openapi/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'rspec-openapi'
5
+ spec.version = RSpec::OpenAPI::VERSION
6
+ spec.authors = ['Takashi Kokubun']
7
+ spec.email = ['takashikkbn@gmail.com']
8
+
9
+ spec.summary = %q{Generate OpenAPI specs from RSpec request specs}
10
+ spec.description = %q{Generate OpenAPI specs from RSpec request specs}
11
+ spec.homepage = 'https://github.com/k0kubun/rspec-openapi'
12
+ spec.license = 'MIT'
13
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0')
14
+
15
+ spec.metadata['homepage_uri'] = spec.homepage
16
+ spec.metadata['source_code_uri'] = spec.homepage
17
+ # spec.metadata['changelog_uri'] = 'TODO'
18
+
19
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
20
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
21
+ end
22
+ spec.bindir = 'exe'
23
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
+ spec.require_paths = ['lib']
25
+
26
+ spec.add_dependency 'rspec'
27
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspec-openapi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Takashi Kokubun
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2020-06-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: Generate OpenAPI specs from RSpec request specs
28
+ email:
29
+ - takashikkbn@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".gitignore"
35
+ - ".rspec"
36
+ - ".travis.yml"
37
+ - CHANGELOG.md
38
+ - Gemfile
39
+ - LICENSE.txt
40
+ - README.md
41
+ - Rakefile
42
+ - bin/console
43
+ - bin/setup
44
+ - lib/rspec/openapi.rb
45
+ - lib/rspec/openapi/default_schema.rb
46
+ - lib/rspec/openapi/hooks.rb
47
+ - lib/rspec/openapi/record.rb
48
+ - lib/rspec/openapi/record_builder.rb
49
+ - lib/rspec/openapi/schema_builder.rb
50
+ - lib/rspec/openapi/schema_file.rb
51
+ - lib/rspec/openapi/schema_merger.rb
52
+ - lib/rspec/openapi/version.rb
53
+ - rspec-openapi.gemspec
54
+ homepage: https://github.com/k0kubun/rspec-openapi
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ homepage_uri: https://github.com/k0kubun/rspec-openapi
59
+ source_code_uri: https://github.com/k0kubun/rspec-openapi
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 2.5.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.1.2
76
+ signing_key:
77
+ specification_version: 4
78
+ summary: Generate OpenAPI specs from RSpec request specs
79
+ test_files: []