openapi_first 0.7.0.alpha1 → 3.1.0.beta1

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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +585 -2
  3. data/LICENSE.txt +21 -0
  4. data/README.md +360 -114
  5. data/lib/openapi_first/builder.rb +214 -0
  6. data/lib/openapi_first/child_configuration.rb +26 -0
  7. data/lib/openapi_first/configuration.rb +76 -0
  8. data/lib/openapi_first/definition.rb +98 -13
  9. data/lib/openapi_first/error_response.rb +40 -0
  10. data/lib/openapi_first/error_responses/default.rb +74 -0
  11. data/lib/openapi_first/error_responses/jsonapi.rb +60 -0
  12. data/lib/openapi_first/errors.rb +40 -0
  13. data/lib/openapi_first/failure.rb +77 -0
  14. data/lib/openapi_first/file_loader.rb +22 -0
  15. data/lib/openapi_first/header.rb +9 -0
  16. data/lib/openapi_first/json.rb +25 -0
  17. data/lib/openapi_first/middlewares/request_validation.rb +58 -0
  18. data/lib/openapi_first/middlewares/response_validation.rb +41 -0
  19. data/lib/openapi_first/parsed_request.rb +5 -0
  20. data/lib/openapi_first/ref_resolver.rb +193 -0
  21. data/lib/openapi_first/registry.rb +44 -0
  22. data/lib/openapi_first/request.rb +79 -0
  23. data/lib/openapi_first/request_body_parsers.rb +64 -0
  24. data/lib/openapi_first/request_validator.rb +32 -0
  25. data/lib/openapi_first/response.rb +43 -0
  26. data/lib/openapi_first/response_body_parsers.rb +31 -0
  27. data/lib/openapi_first/response_parser.rb +50 -0
  28. data/lib/openapi_first/response_validator.rb +16 -48
  29. data/lib/openapi_first/router/find_content.rb +17 -0
  30. data/lib/openapi_first/router/find_response.rb +53 -0
  31. data/lib/openapi_first/router/path_template.rb +48 -0
  32. data/lib/openapi_first/router.rb +84 -57
  33. data/lib/openapi_first/schema/hash.rb +39 -0
  34. data/lib/openapi_first/schema/validation_error.rb +45 -0
  35. data/lib/openapi_first/schema/validation_result.rb +30 -0
  36. data/lib/openapi_first/test/app.rb +30 -0
  37. data/lib/openapi_first/test/callable.rb +25 -0
  38. data/lib/openapi_first/test/configuration.rb +76 -0
  39. data/lib/openapi_first/test/coverage/covered_request.rb +11 -0
  40. data/lib/openapi_first/test/coverage/covered_response.rb +11 -0
  41. data/lib/openapi_first/test/coverage/plan.rb +77 -0
  42. data/lib/openapi_first/test/coverage/request_task.rb +42 -0
  43. data/lib/openapi_first/test/coverage/response_task.rb +42 -0
  44. data/lib/openapi_first/test/coverage/route_task.rb +13 -0
  45. data/lib/openapi_first/test/coverage/terminal_formatter.rb +115 -0
  46. data/lib/openapi_first/test/coverage/tracker.rb +32 -0
  47. data/lib/openapi_first/test/coverage.rb +102 -0
  48. data/lib/openapi_first/test/methods.rb +62 -0
  49. data/lib/openapi_first/test/minitest_helpers.rb +28 -0
  50. data/lib/openapi_first/test/observe.rb +37 -0
  51. data/lib/openapi_first/test/observer_middleware.rb +23 -0
  52. data/lib/openapi_first/test/plain_helpers.rb +31 -0
  53. data/lib/openapi_first/test.rb +171 -0
  54. data/lib/openapi_first/validated_request.rb +79 -0
  55. data/lib/openapi_first/validated_response.rb +53 -0
  56. data/lib/openapi_first/validators/request_body.rb +25 -0
  57. data/lib/openapi_first/validators/request_parameters.rb +49 -0
  58. data/lib/openapi_first/validators/response_body.rb +27 -0
  59. data/lib/openapi_first/validators/response_headers.rb +40 -0
  60. data/lib/openapi_first/version.rb +1 -1
  61. data/lib/openapi_first.rb +67 -35
  62. metadata +99 -133
  63. data/.github/CODEOWNERS +0 -1
  64. data/.gitignore +0 -11
  65. data/.rspec +0 -3
  66. data/.rubocop.yml +0 -14
  67. data/.travis.yml +0 -8
  68. data/Gemfile +0 -11
  69. data/Gemfile.lock +0 -126
  70. data/Rakefile +0 -15
  71. data/benchmarks/Gemfile +0 -13
  72. data/benchmarks/Gemfile.lock +0 -144
  73. data/benchmarks/apps/committee.ru +0 -30
  74. data/benchmarks/apps/grape.ru +0 -21
  75. data/benchmarks/apps/hanami_router.ru +0 -14
  76. data/benchmarks/apps/openapi.yaml +0 -86
  77. data/benchmarks/apps/openapi_first.ru +0 -22
  78. data/benchmarks/apps/openapi_first_resolve_only.ru +0 -23
  79. data/benchmarks/apps/sinatra.ru +0 -26
  80. data/benchmarks/apps/syro.ru +0 -25
  81. data/benchmarks/benchmarks.rb +0 -46
  82. data/bin/console +0 -15
  83. data/bin/setup +0 -8
  84. data/examples/README.md +0 -13
  85. data/examples/app.rb +0 -12
  86. data/examples/config.ru +0 -7
  87. data/examples/openapi.yaml +0 -29
  88. data/lib/openapi_first/app.rb +0 -27
  89. data/lib/openapi_first/coverage.rb +0 -28
  90. data/lib/openapi_first/operation.rb +0 -48
  91. data/lib/openapi_first/operation_resolver.rb +0 -43
  92. data/lib/openapi_first/request_validation.rb +0 -144
  93. data/lib/openapi_first/utils.rb +0 -31
  94. data/lib/openapi_first/validation.rb +0 -15
  95. data/lib/openapi_first/validation_format.rb +0 -31
  96. data/openapi_first.gemspec +0 -46
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ # Helper class to setup tests
6
+ class Configuration
7
+ def initialize
8
+ @minimum_coverage = 100
9
+ @coverage_formatter = Coverage::TerminalFormatter
10
+ @coverage_formatter_options = {}
11
+ @skip_response_coverage = nil
12
+ @skip_coverage = nil
13
+ @response_raise_error = true
14
+ @ignored_unknown_status = Set.new([401, 404, 500])
15
+ @ignore_unknown_response_status = false
16
+ @report_coverage = true
17
+ @ignore_unknown_requests = false
18
+ end
19
+
20
+ # Register OADs, but don't load them just yet
21
+ # @param [OpenapiFirst::OAD] oad The OAD to register
22
+ # @param [Symbol] as The name to register the OAD under
23
+ def register(oad, as: :default)
24
+ Test.register(oad, as:)
25
+ end
26
+
27
+ # Observe a rack app
28
+ def observe(app, api: :default)
29
+ Observe.observe(app, api:)
30
+ end
31
+
32
+ attr_accessor :coverage_formatter_options, :coverage_formatter, :response_raise_error,
33
+ :ignore_unknown_requests, :ignore_unknown_response_status, :minimum_coverage
34
+ attr_reader :report_coverage, :ignored_unknown_status
35
+
36
+ # Set ignored unknown status codes.
37
+ # @param [Array<Integer>] status Status codes that are okay not to cover in an OAD
38
+ def ignored_unknown_status=(status)
39
+ @ignored_unknown_status = status.to_set
40
+ end
41
+
42
+ # Configure report coverage
43
+ # @param [Boolean, :warn] value Whether to report coverage or just warn.
44
+ def report_coverage=(value)
45
+ allowed_values = [true, false, :warn]
46
+ unless allowed_values.include?(value)
47
+ raise ArgumentError, "'report_coverage' must be one of #{allowed_values}, but was #{value.inspect}"
48
+ end
49
+
50
+ @report_coverage = value
51
+ end
52
+
53
+ def skip_response_coverage(&block)
54
+ return @skip_response_coverage unless block_given?
55
+
56
+ @skip_response_coverage = block
57
+ end
58
+
59
+ def skip_coverage(&block)
60
+ return @skip_coverage unless block_given?
61
+
62
+ @skip_coverage = block
63
+ end
64
+
65
+ alias ignore_unknown_response_status? ignore_unknown_response_status
66
+
67
+ def ignore_response?(validated_response)
68
+ return false if validated_response.known?
69
+
70
+ return true if ignored_unknown_status.include?(validated_response.status)
71
+
72
+ ignore_unknown_response_status? && validated_response.error.type == :response_status_not_found
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ module Coverage
6
+ CoveredRequest = Data.define(:key, :error) do
7
+ def valid? = error.nil?
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ module Coverage
6
+ CoveredResponse = Data.define(:key, :error) do
7
+ def valid? = error.nil?
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'route_task'
4
+ require_relative 'response_task'
5
+ require_relative 'request_task'
6
+
7
+ module OpenapiFirst
8
+ module Test
9
+ module Coverage
10
+ # This stores the coverage data for one API description
11
+ # A plan can be #done? and has several #tasks which can be #finished?
12
+ class Plan
13
+ class UnknownRequestError < StandardError; end
14
+
15
+ def self.for(oad, skip_response: nil, skip_route: nil)
16
+ plan = new(definition_key: oad.key, filepath: oad.filepath)
17
+ routes = oad.routes
18
+ routes = routes.reject { |route| skip_route[route.path, route.request_method] } if skip_route
19
+ routes.each do |route|
20
+ responses = skip_response ? route.responses.reject(&skip_response) : route.responses
21
+ plan.add_route request_method: route.request_method,
22
+ path: route.path,
23
+ requests: route.requests,
24
+ responses:
25
+ end
26
+ plan
27
+ end
28
+
29
+ def initialize(definition_key:, filepath: nil)
30
+ @routes = []
31
+ @index = {}
32
+ @api_identifier = filepath || definition_key
33
+ @filepath = filepath
34
+ end
35
+
36
+ attr_reader :api_identifier, :filepath, :routes
37
+ private attr_reader :index
38
+
39
+ def track_request(validated_request)
40
+ index[validated_request.key]&.track(validated_request)
41
+ end
42
+
43
+ def track_response(validated_response)
44
+ index[validated_response.key]&.track(validated_response)
45
+ end
46
+
47
+ def done?
48
+ tasks.all?(&:finished?)
49
+ end
50
+
51
+ def coverage
52
+ done = tasks.count(&:finished?)
53
+ return 0 if done.zero?
54
+
55
+ all = tasks.count
56
+ return 100 if done == all
57
+
58
+ (done / (all.to_f / 100))
59
+ end
60
+
61
+ def tasks
62
+ index.values
63
+ end
64
+
65
+ def add_route(request_method:, path:, requests:, responses:)
66
+ request_tasks = requests.to_a.map do |request|
67
+ index[request.key] = RequestTask.new(request)
68
+ end
69
+ response_tasks = responses.to_a.map do |response|
70
+ index[response.key] = ResponseTask.new(response)
71
+ end
72
+ @routes << RouteTask.new(path:, request_method:, requests: request_tasks, responses: response_tasks)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module OpenapiFirst
6
+ module Test
7
+ module Coverage
8
+ # @visibility private
9
+ class RequestTask
10
+ extend Forwardable
11
+
12
+ def_delegators :@request, :path, :request_method, :content_type
13
+
14
+ def initialize(request_definition)
15
+ @request = request_definition
16
+ @requested = false
17
+ @last_error_message = nil
18
+ end
19
+
20
+ attr_reader :request, :last_error_message
21
+
22
+ def track(validated_request)
23
+ @requested = true
24
+ @valid ||= true if validated_request.valid?
25
+ @last_error_message = validated_request.error.exception_message unless validated_request.valid?
26
+ end
27
+
28
+ def requested?
29
+ @requested == true
30
+ end
31
+
32
+ def any_valid_request?
33
+ @valid == true
34
+ end
35
+
36
+ def finished?
37
+ requested? && any_valid_request?
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module OpenapiFirst
6
+ module Test
7
+ module Coverage
8
+ # @visibility private
9
+ class ResponseTask
10
+ extend Forwardable
11
+
12
+ def_delegators :@response, :status, :content_type, :key
13
+
14
+ def initialize(response_definition)
15
+ @response = response_definition
16
+ @responded = false
17
+ @last_error_message = nil
18
+ end
19
+
20
+ attr_reader :response, :last_error_message
21
+
22
+ def track(validated_response)
23
+ @responded = true
24
+ @valid ||= true if validated_response.valid?
25
+ @last_error_message = validated_response.error.exception_message unless validated_response.valid?
26
+ end
27
+
28
+ def responded?
29
+ @responded == true
30
+ end
31
+
32
+ def any_valid_response?
33
+ @valid == true
34
+ end
35
+
36
+ def finished?
37
+ responded? && any_valid_response?
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ module Coverage
6
+ RouteTask = Data.define(:path, :request_method, :requests, :responses) do
7
+ def finished?
8
+ requests.all?(&:finished?) && responses.all?(&:finished?)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ module Coverage
6
+ # This is the default formatter
7
+ class TerminalFormatter
8
+ def initialize(verbose: false, focused: true)
9
+ @verbose = verbose
10
+ @focused = focused && !verbose
11
+ end
12
+
13
+ def format(coverage_result)
14
+ coverage = coverage_result.coverage
15
+ @out = StringIO.new
16
+ if coverage.zero?
17
+ @out.puts 'API Coverage did not detect any API requests for the registered API descriptions. ' \
18
+ 'Make sure to observe your application using OpenapiFirst::Test.'
19
+ end
20
+ coverage_result.plans.each { |plan| format_plan(plan) } if coverage.positive?
21
+ @out.string
22
+ end
23
+
24
+ private attr_reader :out, :verbose, :focused
25
+
26
+ private
27
+
28
+ def puts(string)
29
+ @out.puts(string)
30
+ end
31
+
32
+ def print(string)
33
+ @out.print(string)
34
+ end
35
+
36
+ def format_plan(plan) # rubocop:disable Metrics/PerceivedComplexity
37
+ puts ['', "API validation coverage for #{plan.api_identifier}: #{plan.coverage}%"]
38
+ return if plan.done? && !verbose
39
+
40
+ requested_routes_count = plan.routes.count { |route| route.requests.any?(&:requested?) }
41
+ focused_route = requested_routes_count <= 1 && focused
42
+
43
+ plan.routes.each do |route|
44
+ next if route.finished? && !verbose
45
+
46
+ next if route.requests.none?(&:requested?) && focused_route
47
+
48
+ format_requests(route.requests)
49
+
50
+ format_responses(route.responses)
51
+ end
52
+ end
53
+
54
+ def format_requests(requests)
55
+ requests.each do |request|
56
+ if request.finished?
57
+ puts green "✓ #{request_label(request)}"
58
+ else
59
+ puts red "❌ #{request_label(request)} – #{explain_unfinished_request(request)}"
60
+ end
61
+ end
62
+ end
63
+
64
+ def format_responses(responses)
65
+ responses.each do |response|
66
+ if response.finished?
67
+ puts green " ✓ #{response_label(response)}" if verbose
68
+ else
69
+ puts red " ❌ #{response_label(response)} – #{explain_unfinished_response(response)}"
70
+ end
71
+ end
72
+ end
73
+
74
+ def green(text)
75
+ "\e[32m#{text}\e[0m"
76
+ end
77
+
78
+ def red(text)
79
+ "\e[31m#{text}\e[0m"
80
+ end
81
+
82
+ def orange(text)
83
+ "\e[33m#{text}\e[0m"
84
+ end
85
+
86
+ def request_label(request)
87
+ name = "#{request.request_method.upcase} #{request.path}" # TODO: add required query parameters?
88
+ name << " (#{request.content_type})" if request.content_type
89
+ name
90
+ end
91
+
92
+ def explain_unfinished_request(request)
93
+ return 'No requests tracked!' unless request.requested?
94
+
95
+ return if request.any_valid_request?
96
+
97
+ "All requests invalid! (#{request.last_error_message.inspect})"
98
+ end
99
+
100
+ def response_label(response)
101
+ name = +''
102
+ name += response.status.to_s
103
+ name += "(#{response.content_type})" if response.content_type
104
+ name
105
+ end
106
+
107
+ def explain_unfinished_response(response)
108
+ return 'No responses tracked!' unless response.responded?
109
+
110
+ "All responses invalid! (#{response.last_error_message.inspect})" unless response.any_valid_response?
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ module Coverage
6
+ # Class that allows tracking requests and response for OAD definitions.
7
+ # For each definition it builds a plan and forwards tracking to the correct plan.
8
+ class Tracker
9
+ attr_reader :plans_by_key
10
+
11
+ def initialize(definitions, skip_response: nil, skip_route: nil)
12
+ @plans_by_key = definitions.values.to_h do |oad|
13
+ plan = Plan.for(oad, skip_response:, skip_route:)
14
+ [oad.key, plan]
15
+ end
16
+ end
17
+
18
+ def track_request(key, request)
19
+ @plans_by_key[key]&.track_request(request)
20
+ end
21
+
22
+ def track_response(key, response)
23
+ @plans_by_key[key]&.track_response(response)
24
+ end
25
+
26
+ def plans
27
+ @plans_by_key.values
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'coverage/plan'
4
+ require_relative 'coverage/tracker'
5
+ require_relative 'coverage/covered_request'
6
+ require_relative 'coverage/covered_response'
7
+ require 'drb'
8
+
9
+ module OpenapiFirst
10
+ module Test
11
+ # The Coverage module is about tracking request and response validation
12
+ # to assess if all parts of the API description have been tested.
13
+ # Currently it does not care about unknown requests that are not part of any API description.
14
+ module Coverage
15
+ autoload :TerminalFormatter, 'openapi_first/test/coverage/terminal_formatter'
16
+
17
+ Result = Data.define(:plans, :coverage)
18
+
19
+ class << self
20
+ def start(skip_response: nil, skip_route: nil)
21
+ return if @drb_uri
22
+
23
+ tracker = Tracker.new(Test.definitions, skip_response:, skip_route:)
24
+
25
+ # We need a custom DRbServer (not using DRb.start_service) because otherwise
26
+ # we'd conflict with Rails's DRb server
27
+ @drb_uri = DRb::DRbServer.new(nil, tracker).uri
28
+ end
29
+
30
+ # Clear current coverage run
31
+ def reset
32
+ @tracker = nil
33
+
34
+ return unless @drb_uri
35
+
36
+ service = DRb.fetch_server(@drb_uri)
37
+ service&.stop_service
38
+ @drb_uri = nil
39
+ end
40
+
41
+ def track_request(request, oad)
42
+ return unless request.known?
43
+
44
+ # The call to `track_request` may happen remotely in the main process that started
45
+ # the coverage collection.
46
+ # To make this work we need to keep arguments trivial, which is the reason the request
47
+ # is wrapped in a CoveredRequest data object.
48
+ tracker&.track_request(
49
+ oad.key,
50
+ CoveredRequest.new(
51
+ key: request.request_definition.key,
52
+ error: request.error
53
+ )
54
+ )
55
+ end
56
+
57
+ def track_response(response, _request, oad)
58
+ return unless response.known?
59
+
60
+ # The call to `track_response` may happen remotely in the main process that started
61
+ # the coverage collection.
62
+ # To make this work we need to keep arguments trivial, which is the reason the response
63
+ # is wrapped in a CoveredResponse data object.
64
+ tracker&.track_response(
65
+ oad.key,
66
+ CoveredResponse.new(
67
+ key: response.response_definition.key,
68
+ error: response.error
69
+ )
70
+ )
71
+ end
72
+
73
+ def result
74
+ Result.new(plans:, coverage:)
75
+ end
76
+
77
+ private
78
+
79
+ def current_run
80
+ tracker.plans_by_key
81
+ end
82
+
83
+ # Returns all plans (Plan) that were registered for this run
84
+ def plans
85
+ tracker&.plans || []
86
+ end
87
+
88
+ def tracker
89
+ return unless @drb_uri
90
+
91
+ @tracker ||= DRbObject.new_with_uri(@drb_uri)
92
+ end
93
+
94
+ def coverage
95
+ return 0 if plans.empty?
96
+
97
+ plans.sum(&:coverage) / plans.length
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'minitest_helpers'
4
+ require_relative 'plain_helpers'
5
+
6
+ module OpenapiFirst
7
+ module Test
8
+ # Methods to use in integration tests
9
+ module Methods
10
+ def self.included(base)
11
+ base.include(DefaultApiMethod)
12
+ base.include(AssertionMethod)
13
+ end
14
+
15
+ def self.[](application_under_test = nil, api: nil)
16
+ mod = Module.new do
17
+ def self.included(base)
18
+ base.include OpenapiFirst::Test::Methods::AssertionMethod
19
+ end
20
+ end
21
+
22
+ if api
23
+ mod.define_method(:openapi_first_default_api) { api }
24
+ else
25
+ mod.include(DefaultApiMethod)
26
+ end
27
+
28
+ if application_under_test
29
+ mod.define_method(:app) { OpenapiFirst::Test.app(application_under_test, api: openapi_first_default_api) }
30
+ end
31
+
32
+ mod
33
+ end
34
+
35
+ # Default methods
36
+ module DefaultApiMethod
37
+ # This is the default api that is used by assert_api_conform
38
+ # :default is the default name that is used if you don't pass an `api:` option to `OpenapiFirst::Test.register`
39
+ # This is overwritten if you pass an `api:` option to `include OpenapiFirst::Test::Methods[…]`
40
+ def openapi_first_default_api
41
+ klass = self.class
42
+ if klass.respond_to?(:metadata) && klass.metadata[:api]
43
+ klass.metadata[:api]
44
+ else
45
+ :default
46
+ end
47
+ end
48
+ end
49
+
50
+ # @visibility private
51
+ module AssertionMethod
52
+ def self.included(base)
53
+ if Test.minitest?(base)
54
+ base.include(OpenapiFirst::Test::MinitestHelpers)
55
+ else
56
+ base.include(OpenapiFirst::Test::PlainHelpers)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ # Assertion methods for Minitest
6
+ module MinitestHelpers
7
+ # :nocov:
8
+ def assert_api_conform(status: nil, api: openapi_first_default_api)
9
+ api = OpenapiFirst::Test[api]
10
+ request = respond_to?(:last_request) ? last_request : @request
11
+ response = respond_to?(:last_response) ? last_response : @response
12
+
13
+ if status
14
+ assert_equal status, response.status,
15
+ "Expected status #{status}, but got #{response.status} " \
16
+ "from #{request.request_method.upcase} #{request.path}."
17
+ end
18
+
19
+ validated_request = api.validate_request(request, raise_error: false)
20
+ validated_response = api.validate_response(request, response, raise_error: false)
21
+
22
+ assert validated_request.valid?, validated_request.error&.exception_message
23
+ assert validated_response.valid?, validated_response.error&.exception_message
24
+ end
25
+ # :nocov:
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'callable'
4
+
5
+ module OpenapiFirst
6
+ module Test
7
+ class ObserveError < Error; end
8
+
9
+ # @visible private
10
+ module Observed; end
11
+
12
+ # Inject silent request/response validation to observe rack apps during testing
13
+ module Observe
14
+ def self.observe(app, api: :default)
15
+ definition = OpenapiFirst::Test[api]
16
+ mod = OpenapiFirst::Test::Callable[definition]
17
+
18
+ if app.respond_to?(:call)
19
+ return if app.singleton_class.include?(Observed)
20
+
21
+ app.singleton_class.prepend(mod)
22
+ app.singleton_class.include(Observed)
23
+ return
24
+ end
25
+
26
+ unless app.instance_methods.include?(:call)
27
+ raise ObserveError, "Don't know how to observe #{app}, because it has no call instance method."
28
+ end
29
+
30
+ return if app.include?(Observed)
31
+
32
+ app.prepend(mod)
33
+ app.include(Observed)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ # Middleware that observes requests and responses. This is used to trigger hooks added by OpenapiFirst::Tests.
6
+ class ObserverMiddleware
7
+ def initialize(app, options = {})
8
+ @app = app
9
+ @definition = OpenapiFirst::Test[options.fetch(:api, :default)]
10
+ end
11
+
12
+ def call(env)
13
+ request = Rack::Request.new(env)
14
+
15
+ @definition.validate_request(request, raise_error: false)
16
+ response = @app.call(env)
17
+ status, headers, body = response
18
+ @definition.validate_response(request, Rack::Response[status, headers, body], raise_error: false)
19
+ response
20
+ end
21
+ end
22
+ end
23
+ end