rspec-openapi 0.1.0 → 0.18.4

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +8 -0
  3. data/.github/release.yaml +24 -0
  4. data/.github/workflows/codeql-analysis.yml +39 -0
  5. data/.github/workflows/rubocop.yml +35 -0
  6. data/.github/workflows/test.yml +46 -0
  7. data/.rspec +2 -1
  8. data/.rubocop.yml +25 -0
  9. data/.rubocop_todo.yml +52 -0
  10. data/.simplecov_spawn.rb +16 -0
  11. data/CHANGELOG.md +287 -0
  12. data/Gemfile +28 -2
  13. data/README.md +267 -28
  14. data/Rakefile +8 -4
  15. data/bin/console +4 -3
  16. data/lib/rspec/openapi/components_updater.rb +98 -0
  17. data/lib/rspec/openapi/default_schema.rb +14 -2
  18. data/lib/rspec/openapi/extractors/hanami.rb +110 -0
  19. data/lib/rspec/openapi/extractors/rack.rb +31 -0
  20. data/lib/rspec/openapi/extractors/rails.rb +66 -0
  21. data/lib/rspec/openapi/extractors.rb +5 -0
  22. data/lib/rspec/openapi/hash_helper.rb +43 -0
  23. data/lib/rspec/openapi/key_transformer.rb +25 -0
  24. data/lib/rspec/openapi/minitest_hooks.rb +50 -0
  25. data/lib/rspec/openapi/record.rb +13 -3
  26. data/lib/rspec/openapi/record_builder.rb +65 -21
  27. data/lib/rspec/openapi/result_recorder.rb +63 -0
  28. data/lib/rspec/openapi/rspec_hooks.rb +21 -0
  29. data/lib/rspec/openapi/schema_builder.rb +166 -41
  30. data/lib/rspec/openapi/schema_cleaner.rb +129 -0
  31. data/lib/rspec/openapi/schema_file.rb +25 -3
  32. data/lib/rspec/openapi/schema_merger.rb +91 -21
  33. data/lib/rspec/openapi/schema_sorter.rb +35 -0
  34. data/lib/rspec/openapi/shared_hooks.rb +15 -0
  35. data/lib/rspec/openapi/version.rb +3 -1
  36. data/lib/rspec/openapi.rb +88 -2
  37. data/rspec-openapi.gemspec +19 -11
  38. data/scripts/rspec +11 -0
  39. data/scripts/rspec_with_simplecov +48 -0
  40. data/test.png +0 -0
  41. metadata +69 -15
  42. data/.travis.yml +0 -6
  43. data/lib/rspec/openapi/hooks.rb +0 -24
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Extractor for rails
4
+ class << RSpec::OpenAPI::Extractors::Rails = Object.new
5
+ # @param [ActionDispatch::Request] request
6
+ # @param [RSpec::Core::Example] example
7
+ # @return Array
8
+ def request_attributes(request, example)
9
+ # Reverse the destructive modification by Rails https://github.com/rails/rails/blob/v6.0.3.4/actionpack/lib/action_dispatch/journey/router.rb#L33-L41
10
+ fixed_request = request.dup
11
+ fixed_request.path_info = File.join(request.script_name, request.path_info) if request.script_name.present?
12
+
13
+ route, path = find_rails_route(fixed_request)
14
+
15
+ return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless path
16
+
17
+ raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil?
18
+
19
+ metadata = example.metadata[:openapi] || {}
20
+ summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
21
+ tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
22
+ operation_id = metadata[:operation_id]
23
+ required_request_params = metadata[:required_request_params] || []
24
+ security = metadata[:security]
25
+ description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
26
+ deprecated = metadata[:deprecated]
27
+ raw_path_params = request.path_parameters
28
+
29
+ summary ||= route.requirements[:action]
30
+ tags ||= [route.requirements[:controller]&.classify].compact
31
+ # :controller and :action always exist. :format is added when routes is configured as such.
32
+ # TODO: Use .except(:controller, :action, :format) when we drop support for Ruby 2.x
33
+ raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params))
34
+
35
+ summary ||= "#{request.method} #{path}"
36
+
37
+ [path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated]
38
+ end
39
+
40
+ # @param [RSpec::ExampleGroups::*] context
41
+ def request_response(context)
42
+ [context.request, context.response]
43
+ end
44
+
45
+ # @param [ActionDispatch::Request] request
46
+ def find_rails_route(request, app: Rails.application, path_prefix: '')
47
+ app.routes.router.recognize(request) do |route, _parameters|
48
+ path = route.path.spec.to_s.delete_suffix('(.:format)')
49
+
50
+ if route.app.matches?(request)
51
+ if route.app.engine?
52
+ route, path = find_rails_route(request, app: route.app.app, path_prefix: path)
53
+ next if route.nil?
54
+ end
55
+
56
+ # Params are empty when it is Engine or Rack app.
57
+ # In that case, we can't handle parameters in path.
58
+ return [route, nil] if request.params.empty?
59
+
60
+ return [route, path_prefix + path]
61
+ end
62
+ end
63
+
64
+ nil
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Create namespace
4
+ module RSpec::OpenAPI::Extractors
5
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class << RSpec::OpenAPI::HashHelper = Object.new
4
+ def paths_to_all_fields(obj)
5
+ case obj
6
+ when Hash
7
+ obj.each.flat_map do |k, v|
8
+ k = k.to_sym
9
+ [[k]] + paths_to_all_fields(v).map { |x| [k, *x] }
10
+ end
11
+ when Array
12
+ obj.flat_map.with_index do |value, i|
13
+ [[i]] + paths_to_all_fields(value).map { |x| [i, *x] }
14
+ end
15
+ else
16
+ []
17
+ end
18
+ end
19
+
20
+ def matched_paths(obj, selector)
21
+ selector_parts = selector.split('.').map(&:to_sym)
22
+ paths_to_all_fields(obj).select do |key_parts|
23
+ key_parts.size == selector_parts.size && key_parts.zip(selector_parts).all? do |kp, sp|
24
+ kp == sp || (sp == :* && !kp.nil?)
25
+ end
26
+ end
27
+ end
28
+
29
+ def matched_paths_deeply_nested(obj, begin_selector, end_selector)
30
+ path_depth_sizes = paths_to_all_fields(obj).map(&:size).uniq
31
+ path_depth_sizes.map do |depth|
32
+ begin_selector_count = begin_selector.is_a?(Symbol) ? 0 : begin_selector.count('.')
33
+ end_selector_count = end_selector.is_a?(Symbol) ? 0 : end_selector.count('.')
34
+ diff = depth - begin_selector_count - end_selector_count
35
+ if diff >= 0
36
+ selector = "#{begin_selector}.#{'*.' * diff}#{end_selector}"
37
+ matched_paths(obj, selector)
38
+ else
39
+ []
40
+ end
41
+ end.flatten(1)
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class << RSpec::OpenAPI::KeyTransformer = Object.new
4
+ def symbolize(value)
5
+ case value
6
+ when Hash
7
+ value.to_h { |k, v| [k.to_sym, symbolize(v)] }
8
+ when Array
9
+ value.map { |v| symbolize(v) }
10
+ else
11
+ value
12
+ end
13
+ end
14
+
15
+ def stringify(value)
16
+ case value
17
+ when Hash
18
+ value.to_h { |k, v| [k.to_s, stringify(v)] }
19
+ when Array
20
+ value.map { |v| stringify(v) }
21
+ else
22
+ value
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest'
4
+
5
+ module RSpec::OpenAPI::Minitest
6
+ Example = Struct.new(:context, :description, :metadata, :file_path)
7
+
8
+ module RunPatch
9
+ def run(*args)
10
+ result = super
11
+ if ENV['OPENAPI'] && self.class.openapi?
12
+ file_path = method(name).source_location.first
13
+ human_name = name.sub(/^test_/, '').gsub('_', ' ')
14
+ example = Example.new(self, human_name, {}, file_path)
15
+ path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
16
+ record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor)
17
+ RSpec::OpenAPI.path_records[path] << record if record
18
+ end
19
+ result
20
+ end
21
+ end
22
+
23
+ module ActivateOpenApiClassMethods
24
+ def self.prepended(base)
25
+ base.extend(ClassMethods)
26
+ end
27
+
28
+ module ClassMethods
29
+ def openapi?
30
+ @openapi
31
+ end
32
+
33
+ def openapi!
34
+ @openapi = true
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ Minitest::Test.prepend RSpec::OpenAPI::Minitest::ActivateOpenApiClassMethods
41
+
42
+ if ENV['OPENAPI']
43
+ Minitest::Test.prepend RSpec::OpenAPI::Minitest::RunPatch
44
+
45
+ Minitest.after_run do
46
+ result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records)
47
+ result_recorder.record_results!
48
+ puts result_recorder.error_message if result_recorder.errors?
49
+ end
50
+ end
@@ -1,15 +1,25 @@
1
+ # frozen_string_literal: true
2
+
1
3
  RSpec::OpenAPI::Record = Struct.new(
2
- :method, # @param [String] - "GET"
4
+ :title, # @param [String] - "API Documentation - Statuses"
5
+ :http_method, # @param [String] - "GET"
3
6
  :path, # @param [String] - "/v1/status/:id"
4
7
  :path_params, # @param [Hash] - {:controller=>"v1/statuses", :action=>"create", :id=>"1"}
5
8
  :query_params, # @param [Hash] - {:query=>"string"}
6
9
  :request_params, # @param [Hash] - {:request=>"body"}
10
+ :required_request_params, # @param [Array] - ["param1", "param2"]
7
11
  :request_content_type, # @param [String] - "application/json"
8
- :controller, # @param [String] - "v1/statuses"
9
- :action, # @param [String] - "show"
12
+ :request_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]]
13
+ :summary, # @param [String] - "v1/statuses #show"
14
+ :tags, # @param [Array] - ["Status"]
15
+ :operation_id, # @param [String] - "request-1234"
10
16
  :description, # @param [String] - "returns a status"
17
+ :security, # @param [Array] - [{securityScheme1: []}]
18
+ :deprecated, # @param [Boolean] - true
11
19
  :status, # @param [Integer] - 200
12
20
  :response_body, # @param [Object] - {"status" => "ok"}
21
+ :response_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]]
13
22
  :response_content_type, # @param [String] - "application/json"
23
+ :response_content_disposition, # @param [String] - "inline"
14
24
  keyword_init: true,
15
25
  )
@@ -1,38 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_dispatch'
1
4
  require 'rspec/openapi/record'
2
5
 
3
6
  class << RSpec::OpenAPI::RecordBuilder = Object.new
4
7
  # @param [RSpec::ExampleGroups::*] context
5
8
  # @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)
9
+ # @return [RSpec::OpenAPI::Record,nil]
10
+ def build(context, example:, extractor:)
11
+ request, response = extractor.request_response(context)
12
+ return if request.nil?
13
+
14
+ title = RSpec::OpenAPI.title.then { |t| t.is_a?(Proc) ? t.call(example) : t }
15
+ path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated =
16
+ extractor.request_attributes(request, example)
17
+
18
+ return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) }
19
+
20
+ request_headers, response_headers = extract_headers(request, response)
12
21
 
13
22
  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,
23
+ title: title,
24
+ http_method: request.method,
25
+ path: path,
26
+ path_params: raw_path_params,
17
27
  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,
28
+ request_params: raw_request_params(request),
29
+ required_request_params: required_request_params,
30
+ request_content_type: request.media_type,
31
+ request_headers: request_headers,
32
+ summary: summary,
33
+ tags: tags,
34
+ operation_id: operation_id,
35
+ description: description,
36
+ security: security,
37
+ deprecated: deprecated,
23
38
  status: response.status,
24
- response_body: response.parsed_body,
25
- response_content_type: response.content_type,
39
+ response_body: safe_parse_body(response, response.media_type),
40
+ response_headers: response_headers,
41
+ response_content_type: response.media_type,
42
+ response_content_disposition: response.header['Content-Disposition'],
26
43
  ).freeze
27
44
  end
28
45
 
29
46
  private
30
47
 
31
- # @param [ActionDispatch::Request] request
32
- def find_route(request)
33
- Rails.application.routes.router.recognize(request) do |route|
34
- return route
48
+ def safe_parse_body(response, media_type)
49
+ # Use raw body, because Nokogiri-parsed HTML are modified (new lines injection, meta injection, and so on) :(
50
+ return response.body if media_type == 'text/html'
51
+
52
+ response.parsed_body
53
+ rescue JSON::ParserError
54
+ nil
55
+ end
56
+
57
+ def extract_headers(request, response)
58
+ request_headers = RSpec::OpenAPI.request_headers.each_with_object([]) do |header, headers_arr|
59
+ header_key = header.gsub('-', '_').upcase.to_sym
60
+
61
+ header_value = request.get_header(['HTTP', header_key].join('_')) ||
62
+ request.get_header(header_key) ||
63
+ request.get_header(header_key.to_s)
64
+ headers_arr << [header, header_value] if header_value
65
+ end
66
+ response_headers = RSpec::OpenAPI.response_headers.each_with_object([]) do |header, headers_arr|
67
+ header_key = header
68
+ header_value = response.headers[header_key]
69
+ headers_arr << [header_key, header_value] if header_value
35
70
  end
36
- raise "No route matched for #{request.request_method} #{request.path_info}"
71
+ [request_headers, response_headers]
72
+ end
73
+
74
+ # workaround to get real request parameters
75
+ # because ActionController::ParamsWrapper overwrites request_parameters
76
+ def raw_request_params(request)
77
+ original = request.delete_header('action_dispatch.request.request_parameters')
78
+ request.request_parameters
79
+ ensure
80
+ request.set_header('action_dispatch.request.request_parameters', original)
37
81
  end
38
82
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RSpec::OpenAPI::ResultRecorder
4
+ def initialize(path_records)
5
+ @path_records = path_records
6
+ @error_records = {}
7
+ end
8
+
9
+ def record_results!
10
+ @path_records.each do |path, records|
11
+ # Look for a path-specific config file and run it.
12
+ config_file = File.join(File.dirname(path), RSpec::OpenAPI.config_filename)
13
+ begin
14
+ require config_file if File.exist?(config_file)
15
+ rescue StandardError => e
16
+ puts "WARNING: Unable to load #{config_file}: #{e}"
17
+ end
18
+
19
+ title = records.first.title
20
+ RSpec::OpenAPI::SchemaFile.new(path).edit do |spec|
21
+ schema = RSpec::OpenAPI::DefaultSchema.build(title)
22
+ schema[:info].merge!(RSpec::OpenAPI.info)
23
+ RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
24
+ new_from_zero = {}
25
+ records.each do |record|
26
+ record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
27
+ RSpec::OpenAPI::SchemaMerger.merge!(spec, record_schema)
28
+ RSpec::OpenAPI::SchemaMerger.merge!(new_from_zero, record_schema)
29
+ rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
30
+ @error_records[e] = record # Avoid failing the build
31
+ end
32
+ cleanup_schema!(new_from_zero, spec)
33
+ execute_post_process_hook(path, records, spec)
34
+ end
35
+ end
36
+ end
37
+
38
+ def errors?
39
+ @error_records.any?
40
+ end
41
+
42
+ def error_message
43
+ <<~ERR_MSG
44
+ RSpec::OpenAPI got errors building #{@error_records.size} requests
45
+
46
+ #{@error_records.map { |e, record| "#{e.inspect}: #{record.inspect}" }.join("\n")}
47
+ ERR_MSG
48
+ end
49
+
50
+ private
51
+
52
+ def execute_post_process_hook(path, records, spec)
53
+ RSpec::OpenAPI.post_process_hook.call(path, records, spec) if RSpec::OpenAPI.post_process_hook.is_a?(Proc)
54
+ end
55
+
56
+ def cleanup_schema!(new_from_zero, spec)
57
+ RSpec::OpenAPI::SchemaCleaner.cleanup_conflicting_security_parameters!(spec)
58
+ RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
59
+ RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
60
+ RSpec::OpenAPI::SchemaCleaner.cleanup_empty_required_array!(spec)
61
+ RSpec::OpenAPI::SchemaSorter.deep_sort!(spec)
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+
5
+ RSpec.configuration.after(:each) do |example|
6
+ if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false
7
+ path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
8
+ record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor)
9
+ RSpec::OpenAPI.path_records[path] << record if record
10
+ end
11
+ end
12
+
13
+ RSpec.configuration.after(:suite) do
14
+ result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records)
15
+ result_recorder.record_results!
16
+ if result_recorder.errors?
17
+ error_message = result_recorder.error_message
18
+ colorizer = RSpec::Core::Formatters::ConsoleCodes
19
+ RSpec.configuration.reporter.message colorizer.wrap(error_message, :failure)
20
+ end
21
+ end