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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +585 -2
- data/LICENSE.txt +21 -0
- data/README.md +360 -114
- data/lib/openapi_first/builder.rb +214 -0
- data/lib/openapi_first/child_configuration.rb +26 -0
- data/lib/openapi_first/configuration.rb +76 -0
- data/lib/openapi_first/definition.rb +98 -13
- data/lib/openapi_first/error_response.rb +40 -0
- data/lib/openapi_first/error_responses/default.rb +74 -0
- data/lib/openapi_first/error_responses/jsonapi.rb +60 -0
- data/lib/openapi_first/errors.rb +40 -0
- data/lib/openapi_first/failure.rb +77 -0
- data/lib/openapi_first/file_loader.rb +22 -0
- data/lib/openapi_first/header.rb +9 -0
- data/lib/openapi_first/json.rb +25 -0
- data/lib/openapi_first/middlewares/request_validation.rb +58 -0
- data/lib/openapi_first/middlewares/response_validation.rb +41 -0
- data/lib/openapi_first/parsed_request.rb +5 -0
- data/lib/openapi_first/ref_resolver.rb +193 -0
- data/lib/openapi_first/registry.rb +44 -0
- data/lib/openapi_first/request.rb +79 -0
- data/lib/openapi_first/request_body_parsers.rb +64 -0
- data/lib/openapi_first/request_validator.rb +32 -0
- data/lib/openapi_first/response.rb +43 -0
- data/lib/openapi_first/response_body_parsers.rb +31 -0
- data/lib/openapi_first/response_parser.rb +50 -0
- data/lib/openapi_first/response_validator.rb +16 -48
- data/lib/openapi_first/router/find_content.rb +17 -0
- data/lib/openapi_first/router/find_response.rb +53 -0
- data/lib/openapi_first/router/path_template.rb +48 -0
- data/lib/openapi_first/router.rb +84 -57
- data/lib/openapi_first/schema/hash.rb +39 -0
- data/lib/openapi_first/schema/validation_error.rb +45 -0
- data/lib/openapi_first/schema/validation_result.rb +30 -0
- data/lib/openapi_first/test/app.rb +30 -0
- data/lib/openapi_first/test/callable.rb +25 -0
- data/lib/openapi_first/test/configuration.rb +76 -0
- data/lib/openapi_first/test/coverage/covered_request.rb +11 -0
- data/lib/openapi_first/test/coverage/covered_response.rb +11 -0
- data/lib/openapi_first/test/coverage/plan.rb +77 -0
- data/lib/openapi_first/test/coverage/request_task.rb +42 -0
- data/lib/openapi_first/test/coverage/response_task.rb +42 -0
- data/lib/openapi_first/test/coverage/route_task.rb +13 -0
- data/lib/openapi_first/test/coverage/terminal_formatter.rb +115 -0
- data/lib/openapi_first/test/coverage/tracker.rb +32 -0
- data/lib/openapi_first/test/coverage.rb +102 -0
- data/lib/openapi_first/test/methods.rb +62 -0
- data/lib/openapi_first/test/minitest_helpers.rb +28 -0
- data/lib/openapi_first/test/observe.rb +37 -0
- data/lib/openapi_first/test/observer_middleware.rb +23 -0
- data/lib/openapi_first/test/plain_helpers.rb +31 -0
- data/lib/openapi_first/test.rb +171 -0
- data/lib/openapi_first/validated_request.rb +79 -0
- data/lib/openapi_first/validated_response.rb +53 -0
- data/lib/openapi_first/validators/request_body.rb +25 -0
- data/lib/openapi_first/validators/request_parameters.rb +49 -0
- data/lib/openapi_first/validators/response_body.rb +27 -0
- data/lib/openapi_first/validators/response_headers.rb +40 -0
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +67 -35
- metadata +99 -133
- data/.github/CODEOWNERS +0 -1
- data/.gitignore +0 -11
- data/.rspec +0 -3
- data/.rubocop.yml +0 -14
- data/.travis.yml +0 -8
- data/Gemfile +0 -11
- data/Gemfile.lock +0 -126
- data/Rakefile +0 -15
- data/benchmarks/Gemfile +0 -13
- data/benchmarks/Gemfile.lock +0 -144
- data/benchmarks/apps/committee.ru +0 -30
- data/benchmarks/apps/grape.ru +0 -21
- data/benchmarks/apps/hanami_router.ru +0 -14
- data/benchmarks/apps/openapi.yaml +0 -86
- data/benchmarks/apps/openapi_first.ru +0 -22
- data/benchmarks/apps/openapi_first_resolve_only.ru +0 -23
- data/benchmarks/apps/sinatra.ru +0 -26
- data/benchmarks/apps/syro.ru +0 -25
- data/benchmarks/benchmarks.rb +0 -46
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/examples/README.md +0 -13
- data/examples/app.rb +0 -12
- data/examples/config.ru +0 -7
- data/examples/openapi.yaml +0 -29
- data/lib/openapi_first/app.rb +0 -27
- data/lib/openapi_first/coverage.rb +0 -28
- data/lib/openapi_first/operation.rb +0 -48
- data/lib/openapi_first/operation_resolver.rb +0 -43
- data/lib/openapi_first/request_validation.rb +0 -144
- data/lib/openapi_first/utils.rb +0 -31
- data/lib/openapi_first/validation.rb +0 -15
- data/lib/openapi_first/validation_format.rb +0 -31
- 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,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
|