openapi_first 3.3.1 → 3.4.1
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 +24 -0
- data/README.md +1 -0
- data/lib/openapi_first/builder.rb +5 -2
- data/lib/openapi_first/configuration.rb +6 -0
- data/lib/openapi_first/definition.rb +43 -9
- data/lib/openapi_first/middlewares/request_validation.rb +21 -2
- data/lib/openapi_first/middlewares/response_validation.rb +2 -1
- data/lib/openapi_first/plugins/x_public.rb +29 -0
- data/lib/openapi_first/plugins.rb +44 -0
- data/lib/openapi_first/registry.rb +2 -2
- data/lib/openapi_first/request.rb +28 -15
- data/lib/openapi_first/request_body_parsers.rb +40 -8
- data/lib/openapi_first/request_validator.rb +5 -1
- data/lib/openapi_first/response.rb +2 -12
- data/lib/openapi_first/response_body_parsers.rb +2 -2
- data/lib/openapi_first/response_parser.rb +6 -3
- data/lib/openapi_first/response_validator.rb +4 -3
- data/lib/openapi_first/schema/hash.rb +1 -1
- data/lib/openapi_first/test/configuration.rb +45 -4
- data/lib/openapi_first/test/coverage/html_reporter/context.rb +89 -0
- data/lib/openapi_first/test/coverage/html_reporter.css +179 -0
- data/lib/openapi_first/test/coverage/html_reporter.html.erb +87 -0
- data/lib/openapi_first/test/coverage/html_reporter.rb +30 -0
- data/lib/openapi_first/test/coverage/plan.rb +4 -3
- data/lib/openapi_first/test/coverage/route_task.rb +5 -0
- data/lib/openapi_first/test/coverage/{terminal_formatter.rb → terminal_reporter.rb} +25 -33
- data/lib/openapi_first/test/coverage.rb +15 -7
- data/lib/openapi_first/test/logger.rb +17 -0
- data/lib/openapi_first/test/observe.rb +1 -1
- data/lib/openapi_first/test.rb +16 -6
- data/lib/openapi_first/validators/request_body.rb +3 -2
- data/lib/openapi_first/validators/request_parameters.rb +4 -4
- data/lib/openapi_first/validators/response_body.rb +2 -2
- data/lib/openapi_first/validators/response_headers.rb +4 -3
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +8 -0
- metadata +11 -4
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'logger'
|
|
4
|
+
|
|
3
5
|
module OpenapiFirst
|
|
4
6
|
module Test
|
|
5
7
|
# Helper class to setup tests
|
|
6
8
|
class Configuration
|
|
7
9
|
def initialize
|
|
8
10
|
@minimum_coverage = 100
|
|
9
|
-
@
|
|
10
|
-
@
|
|
11
|
+
@coverage_reporter = Coverage::HtmlReporter
|
|
12
|
+
@coverage_reporter_options = {}
|
|
11
13
|
@skip_response_coverage = nil
|
|
12
14
|
@skip_coverage = nil
|
|
13
15
|
@response_raise_error = true
|
|
@@ -17,6 +19,7 @@ module OpenapiFirst
|
|
|
17
19
|
@ignore_unknown_requests = false
|
|
18
20
|
@ignore_request_error = nil
|
|
19
21
|
@ignore_response_error = nil
|
|
22
|
+
@logger = Logger.new($stdout)
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
# Register OADs, but don't load them just yet
|
|
@@ -31,10 +34,34 @@ module OpenapiFirst
|
|
|
31
34
|
Observe.observe(app, api:)
|
|
32
35
|
end
|
|
33
36
|
|
|
34
|
-
attr_accessor :
|
|
35
|
-
:ignore_unknown_requests, :ignore_unknown_response_status, :minimum_coverage
|
|
37
|
+
attr_accessor :coverage_reporter, :coverage_reporter_options, :response_raise_error,
|
|
38
|
+
:ignore_unknown_requests, :ignore_unknown_response_status, :minimum_coverage, :logger
|
|
36
39
|
attr_reader :report_coverage, :ignored_unknown_status
|
|
37
40
|
|
|
41
|
+
# @deprecated Use {#coverage_reporter} instead.
|
|
42
|
+
def coverage_formatter
|
|
43
|
+
warn_coverage_formatter_deprecation
|
|
44
|
+
coverage_reporter
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @deprecated Use {#coverage_reporter=} instead.
|
|
48
|
+
def coverage_formatter=(value)
|
|
49
|
+
warn_coverage_formatter_deprecation
|
|
50
|
+
self.coverage_reporter = value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @deprecated Use {#coverage_reporter_options} instead.
|
|
54
|
+
def coverage_formatter_options
|
|
55
|
+
warn_coverage_formatter_deprecation
|
|
56
|
+
coverage_reporter_options
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @deprecated Use {#coverage_reporter_options=} instead.
|
|
60
|
+
def coverage_formatter_options=(value)
|
|
61
|
+
warn_coverage_formatter_deprecation
|
|
62
|
+
self.coverage_reporter_options = value
|
|
63
|
+
end
|
|
64
|
+
|
|
38
65
|
# Set ignored unknown status codes.
|
|
39
66
|
# @param [Array<Integer>] status Status codes that are okay not to cover in an OAD
|
|
40
67
|
def ignored_unknown_status=(status)
|
|
@@ -55,7 +82,9 @@ module OpenapiFirst
|
|
|
55
82
|
# Ignore certain errors for certain requests
|
|
56
83
|
# @param block A Proc that will be called with [OpenapiFirst::ValidatedRequest]
|
|
57
84
|
def ignore_request_error(&block)
|
|
85
|
+
# :nocov:
|
|
58
86
|
raise ArgumentError, 'You have to pass a block' unless block_given?
|
|
87
|
+
# :nocov:
|
|
59
88
|
|
|
60
89
|
@ignore_request_error = block
|
|
61
90
|
end
|
|
@@ -63,7 +92,9 @@ module OpenapiFirst
|
|
|
63
92
|
# Ignore certain errors for certain responses
|
|
64
93
|
# @param block A Proc that will be called with [OpenapiFirst::ValidatedResponse, Rack::Request]
|
|
65
94
|
def ignore_response_error(&block)
|
|
95
|
+
# :nocov:
|
|
66
96
|
raise ArgumentError, 'You have to pass a block' unless block_given?
|
|
97
|
+
# :nocov:
|
|
67
98
|
|
|
68
99
|
@ignore_response_error = block
|
|
69
100
|
end
|
|
@@ -99,6 +130,16 @@ module OpenapiFirst
|
|
|
99
130
|
|
|
100
131
|
true
|
|
101
132
|
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def warn_coverage_formatter_deprecation
|
|
137
|
+
return if @coverage_formatter_warned
|
|
138
|
+
|
|
139
|
+
warn 'DEPRECATION WARNING: Test::Configuration#coverage_formatter(_options) is deprecated, ' \
|
|
140
|
+
'use #coverage_reporter(_options) instead.'
|
|
141
|
+
@coverage_formatter_warned = true
|
|
142
|
+
end
|
|
102
143
|
end
|
|
103
144
|
end
|
|
104
145
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module OpenapiFirst
|
|
6
|
+
module Test
|
|
7
|
+
module Coverage
|
|
8
|
+
class HtmlReporter
|
|
9
|
+
# Provides the binding and helper methods for the ERB template.
|
|
10
|
+
class Context
|
|
11
|
+
NO_REQUESTS_WARNING =
|
|
12
|
+
'API Coverage did not detect any API requests for the registered ' \
|
|
13
|
+
'API descriptions. Make sure to observe your application using OpenapiFirst::Test.'
|
|
14
|
+
|
|
15
|
+
attr_reader :coverage, :plans, :verbose
|
|
16
|
+
|
|
17
|
+
def initialize(coverage_result, verbose)
|
|
18
|
+
@coverage = coverage_result.coverage
|
|
19
|
+
@plans = coverage_result.plans
|
|
20
|
+
@verbose = verbose
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Helper for ERB rendering only — exposes this context's binding so the
|
|
24
|
+
# template can resolve helper methods and instance state.
|
|
25
|
+
def get_binding # rubocop:disable Naming/AccessorMethodName
|
|
26
|
+
binding
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def expand_plan?(plan)
|
|
30
|
+
verbose || plan.done?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def visible_routes(plan)
|
|
34
|
+
return plan.routes if expand_plan?(plan)
|
|
35
|
+
|
|
36
|
+
plan.routes.reject(&:finished?)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def any_request_made?(route)
|
|
40
|
+
route.requests.any?(&:requested?)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def route_status(route)
|
|
44
|
+
return :request_problem if route.requests.none?(&:finished?)
|
|
45
|
+
return :responses_problem if any_request_made?(route) && route.responses.any? { |r| !r.finished? }
|
|
46
|
+
|
|
47
|
+
:ok
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def uncovered_responses_count(route)
|
|
51
|
+
route.responses.count { |r| !r.finished? }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def request_items(route, plan_verbose:)
|
|
55
|
+
return [] unless any_request_made?(route) && route.requests.any?(&:content_type)
|
|
56
|
+
|
|
57
|
+
plan_verbose ? route.requests : route.requests.reject(&:finished?)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def response_items(route, plan_verbose:)
|
|
61
|
+
return [] unless plan_verbose || any_request_made?(route)
|
|
62
|
+
return route.responses if plan_verbose || route.responses.any? { |r| !r.finished? }
|
|
63
|
+
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def h(text)
|
|
68
|
+
ERB::Util.html_escape(text)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def explain_unfinished_request(request)
|
|
72
|
+
return 'No requests tracked!' unless request.requested?
|
|
73
|
+
return if request.any_valid_request?
|
|
74
|
+
|
|
75
|
+
"All requests invalid! (#{request.last_error_message.inspect})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def explain_unfinished_response(response, request_made: false)
|
|
79
|
+
unless response.responded?
|
|
80
|
+
return request_made ? 'No matching response tracked!' : 'No responses tracked!'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
"All responses invalid! (#{response.last_error_message.inspect})" unless response.any_valid_response?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
2
|
+
|
|
3
|
+
body {
|
|
4
|
+
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
|
|
5
|
+
background: #0f1117;
|
|
6
|
+
color: #e2e8f0;
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 2rem;
|
|
9
|
+
line-height: 1.6;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
h1 {
|
|
13
|
+
font-size: 1.1rem;
|
|
14
|
+
font-weight: 600;
|
|
15
|
+
margin-block: 0 1rem;
|
|
16
|
+
color: #f8fafc;
|
|
17
|
+
|
|
18
|
+
& .pct {
|
|
19
|
+
font-size: 0.95rem;
|
|
20
|
+
font-weight: 400;
|
|
21
|
+
color: #94a3b8;
|
|
22
|
+
margin-inline-start: 0.5rem;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
details.plan {
|
|
27
|
+
border: 1px solid #1e293b;
|
|
28
|
+
padding: 1.25rem 1.5rem;
|
|
29
|
+
margin-block-end: 1.5rem;
|
|
30
|
+
background: #161b27;
|
|
31
|
+
|
|
32
|
+
&[open] { border-color: #334155; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
details.plan > summary {
|
|
36
|
+
font-size: 1rem;
|
|
37
|
+
font-weight: 600;
|
|
38
|
+
color: #cbd5e1;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
list-style: none;
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: baseline;
|
|
43
|
+
gap: 0.5rem;
|
|
44
|
+
user-select: none;
|
|
45
|
+
|
|
46
|
+
& .pct {
|
|
47
|
+
font-size: 0.8rem;
|
|
48
|
+
font-weight: 400;
|
|
49
|
+
color: #64748b;
|
|
50
|
+
text-transform: none;
|
|
51
|
+
letter-spacing: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
&::before {
|
|
55
|
+
content: '▶';
|
|
56
|
+
font-size: 0.6rem;
|
|
57
|
+
color: #475569;
|
|
58
|
+
transition: transform 0.15s ease;
|
|
59
|
+
align-self: center;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
details[open] > &::before { transform: rotate(90deg); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.plan-content {
|
|
66
|
+
margin-block-start: 1rem;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
details.route {
|
|
70
|
+
border-inline-start: 2px solid #1e293b;
|
|
71
|
+
padding-inline-start: 1rem;
|
|
72
|
+
margin-block: 0.75rem;
|
|
73
|
+
|
|
74
|
+
&[open] { border-color: #334155; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
details.route > summary {
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
color: #94a3b8;
|
|
80
|
+
font-size: 0.85rem;
|
|
81
|
+
text-transform: uppercase;
|
|
82
|
+
letter-spacing: 0.04em;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
list-style: none;
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 0.4rem;
|
|
88
|
+
padding-block: 0.2rem;
|
|
89
|
+
user-select: none;
|
|
90
|
+
|
|
91
|
+
&::before {
|
|
92
|
+
content: '▶';
|
|
93
|
+
font-size: 0.6rem;
|
|
94
|
+
color: #475569;
|
|
95
|
+
transition: transform 0.15s ease;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
details[open] > &::before { transform: rotate(90deg); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.operation-label {
|
|
102
|
+
font-size: 0.8rem;
|
|
103
|
+
font-weight: 400;
|
|
104
|
+
color: #64748b;
|
|
105
|
+
text-transform: none;
|
|
106
|
+
letter-spacing: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.request-status,
|
|
110
|
+
.response-summary {
|
|
111
|
+
font-size: 0.8rem;
|
|
112
|
+
font-weight: 400;
|
|
113
|
+
text-transform: none;
|
|
114
|
+
letter-spacing: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.route-content {
|
|
118
|
+
margin-block-start: 0.4rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
ul.tasks {
|
|
122
|
+
list-style: none;
|
|
123
|
+
margin: 0;
|
|
124
|
+
padding: 0;
|
|
125
|
+
display: flex;
|
|
126
|
+
flex-direction: column;
|
|
127
|
+
gap: 0.2rem;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
ul.responses {
|
|
131
|
+
padding-inline-start: 1.5rem;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
li {
|
|
135
|
+
font-size: 0.875rem;
|
|
136
|
+
padding: 0.2rem 0.4rem;
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: baseline;
|
|
139
|
+
gap: 0.5rem;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.covered { color: #4ade80; }
|
|
143
|
+
|
|
144
|
+
.uncovered {
|
|
145
|
+
background: #1f1315;
|
|
146
|
+
font-weight: 500;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.status {
|
|
150
|
+
color: #e2e8f0;
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
min-width: 3ch;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.content-type {
|
|
156
|
+
color: #64748b;
|
|
157
|
+
font-size: 0.8rem;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.problem {
|
|
161
|
+
color: #f87171;
|
|
162
|
+
font-size: 0.8rem;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.method {
|
|
166
|
+
color: #7dd3fc;
|
|
167
|
+
font-weight: 700;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.path {
|
|
171
|
+
color: #e2e8f0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.warning {
|
|
175
|
+
color: #fbbf24;
|
|
176
|
+
background: #1c1a10;
|
|
177
|
+
border: 1px solid #3b3010;
|
|
178
|
+
padding: 0.75rem 1rem;
|
|
179
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>OpenAPI API Coverage Report</title>
|
|
6
|
+
<style><%= File.read(File.join(__dir__, 'html_reporter.css')) %></style>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<h1>API Coverage <span class="pct"><%= coverage.round(1) %>%</span></h1>
|
|
10
|
+
<% if plans.empty? -%>
|
|
11
|
+
<p class="warning"><%= NO_REQUESTS_WARNING %></p>
|
|
12
|
+
<% else -%>
|
|
13
|
+
<% plans.each do |plan| -%>
|
|
14
|
+
<details class="plan"<%= ' open' unless plan.done? %>>
|
|
15
|
+
<summary>
|
|
16
|
+
<%= h plan.title || plan.api_identifier %>
|
|
17
|
+
<span class="pct"><%= plan.api_identifier %> · <%= plan.coverage.round(1) %>%</span>
|
|
18
|
+
</summary>
|
|
19
|
+
<div class="plan-content">
|
|
20
|
+
<% plan_verbose = expand_plan?(plan) -%>
|
|
21
|
+
<% visible_routes(plan).each do |route| -%>
|
|
22
|
+
<details class="route"<%= ' open' unless route.finished? %>>
|
|
23
|
+
<summary>
|
|
24
|
+
<span class="method"><%= h route.request_method.upcase %></span>
|
|
25
|
+
<span class="path"><%= h route.path %></span>
|
|
26
|
+
<% status = route_status(route) -%>
|
|
27
|
+
<% if status == :request_problem -%>
|
|
28
|
+
<span class="request-status problem">❌ <span class="problem"><%= h explain_unfinished_request(route.requests.first) %></span></span>
|
|
29
|
+
<% elsif status == :responses_problem -%>
|
|
30
|
+
<span class="response-summary problem">⚠️ <%= uncovered_responses_count(route) %> response(s) not covered</span>
|
|
31
|
+
<% else -%>
|
|
32
|
+
<span class="request-status covered">✅</span>
|
|
33
|
+
<% end -%>
|
|
34
|
+
<% if (label = route.summary) -%>
|
|
35
|
+
<span class="operation-label"><%= h label %></span>
|
|
36
|
+
<% end -%>
|
|
37
|
+
</summary>
|
|
38
|
+
<div class="route-content">
|
|
39
|
+
<% requests = request_items(route, plan_verbose: plan_verbose) -%>
|
|
40
|
+
<% unless requests.empty? -%>
|
|
41
|
+
<ul class="tasks">
|
|
42
|
+
<% requests.each do |request| -%>
|
|
43
|
+
<% if request.finished? -%>
|
|
44
|
+
<li class="covered">
|
|
45
|
+
<span>✅</span>
|
|
46
|
+
<% if request.content_type -%><span class="content-type"><%= h request.content_type %></span><% end -%>
|
|
47
|
+
</li>
|
|
48
|
+
<% else -%>
|
|
49
|
+
<li class="uncovered">
|
|
50
|
+
<span>❌</span>
|
|
51
|
+
<% if request.content_type -%><span class="content-type"><%= h request.content_type %></span><% end -%>
|
|
52
|
+
<span class="problem"><%= h explain_unfinished_request(request) %></span>
|
|
53
|
+
</li>
|
|
54
|
+
<% end -%>
|
|
55
|
+
<% end -%>
|
|
56
|
+
</ul>
|
|
57
|
+
<% end -%>
|
|
58
|
+
<% responses = response_items(route, plan_verbose: plan_verbose) -%>
|
|
59
|
+
<% unless responses.empty? -%>
|
|
60
|
+
<ul class="tasks responses">
|
|
61
|
+
<% responses.each do |response| -%>
|
|
62
|
+
<% if response.finished? -%>
|
|
63
|
+
<li class="covered">
|
|
64
|
+
<span>✅</span>
|
|
65
|
+
<span class="status"><%= h response.status %></span>
|
|
66
|
+
<% if response.content_type -%><span class="content-type"><%= h response.content_type %></span><% end -%>
|
|
67
|
+
</li>
|
|
68
|
+
<% else -%>
|
|
69
|
+
<li class="uncovered">
|
|
70
|
+
<span>❌</span>
|
|
71
|
+
<span class="status"><%= h response.status %></span>
|
|
72
|
+
<% if response.content_type -%><span class="content-type"><%= h response.content_type %></span><% end -%>
|
|
73
|
+
<span class="problem"><%= h explain_unfinished_response(response, request_made: any_request_made?(route)) %></span>
|
|
74
|
+
</li>
|
|
75
|
+
<% end -%>
|
|
76
|
+
<% end -%>
|
|
77
|
+
</ul>
|
|
78
|
+
<% end -%>
|
|
79
|
+
</div>
|
|
80
|
+
</details>
|
|
81
|
+
<% end -%>
|
|
82
|
+
</div>
|
|
83
|
+
</details>
|
|
84
|
+
<% end -%>
|
|
85
|
+
<% end -%>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative 'html_reporter/context'
|
|
5
|
+
|
|
6
|
+
module OpenapiFirst
|
|
7
|
+
module Test
|
|
8
|
+
module Coverage
|
|
9
|
+
# Writes a self-contained HTML coverage report to a file.
|
|
10
|
+
class HtmlReporter
|
|
11
|
+
def initialize(output: 'coverage/openapi_coverage.html', verbose: false, logger: Test.logger)
|
|
12
|
+
@output = output
|
|
13
|
+
@verbose = verbose
|
|
14
|
+
@logger = logger
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def report(coverage_result)
|
|
18
|
+
html = TEMPLATE.result(Context.new(coverage_result, @verbose).get_binding)
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(@output))
|
|
20
|
+
File.write(@output, html)
|
|
21
|
+
@logger.info "API coverage report written to #{@output}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
TEMPLATE_PATH = File.join(__dir__, 'html_reporter.html.erb')
|
|
25
|
+
TEMPLATE = ERB.new(File.read(TEMPLATE_PATH), trim_mode: '-')
|
|
26
|
+
TEMPLATE.filename = TEMPLATE_PATH
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -13,7 +13,7 @@ module OpenapiFirst
|
|
|
13
13
|
class UnknownRequestError < StandardError; end
|
|
14
14
|
|
|
15
15
|
def self.for(oad, skip_response: nil, skip_route: nil)
|
|
16
|
-
plan = new(definition_key: oad.key, filepath: oad.filepath)
|
|
16
|
+
plan = new(definition_key: oad.key, filepath: oad.filepath, title: oad.title)
|
|
17
17
|
routes = oad.routes
|
|
18
18
|
routes = routes.reject { |route| skip_route[route.path, route.request_method] } if skip_route
|
|
19
19
|
routes.each do |route|
|
|
@@ -26,14 +26,15 @@ module OpenapiFirst
|
|
|
26
26
|
plan
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(definition_key:, filepath: nil)
|
|
29
|
+
def initialize(definition_key:, filepath: nil, title: nil)
|
|
30
30
|
@routes = []
|
|
31
31
|
@index = {}
|
|
32
32
|
@api_identifier = filepath || definition_key
|
|
33
33
|
@filepath = filepath
|
|
34
|
+
@title = title
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
attr_reader :api_identifier, :filepath, :routes
|
|
37
|
+
attr_reader :api_identifier, :filepath, :routes, :title
|
|
37
38
|
private attr_reader :index
|
|
38
39
|
|
|
39
40
|
def track_request(validated_request)
|
|
@@ -3,38 +3,34 @@
|
|
|
3
3
|
module OpenapiFirst
|
|
4
4
|
module Test
|
|
5
5
|
module Coverage
|
|
6
|
-
#
|
|
7
|
-
class
|
|
8
|
-
def initialize(verbose: false, focused: true)
|
|
6
|
+
# Reports coverage to a logger using ANSI-coloured lines.
|
|
7
|
+
class TerminalReporter
|
|
8
|
+
def initialize(verbose: false, focused: true, logger: Test.logger)
|
|
9
9
|
@verbose = verbose
|
|
10
10
|
@focused = focused && !verbose
|
|
11
|
+
@logger = logger
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def format(coverage_result)
|
|
15
|
+
logger.warn 'DEPRECATION WARNING: TerminalReporter#format is deprecated, use #report instead.'
|
|
16
|
+
report(coverage_result)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def report(coverage_result)
|
|
14
20
|
coverage = coverage_result.coverage
|
|
15
|
-
@out = StringIO.new
|
|
16
21
|
if coverage.zero?
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
logger.warn 'API Coverage did not detect any API requests for the registered API descriptions. ' \
|
|
23
|
+
'Make sure to observe your application using OpenapiFirst::Test.'
|
|
19
24
|
end
|
|
20
25
|
coverage_result.plans.each { |plan| format_plan(plan) } if coverage.positive?
|
|
21
|
-
@out.string
|
|
22
26
|
end
|
|
23
27
|
|
|
24
|
-
private attr_reader :out, :verbose, :focused
|
|
28
|
+
private attr_reader :out, :verbose, :focused, :logger
|
|
25
29
|
|
|
26
30
|
private
|
|
27
31
|
|
|
28
|
-
def puts(string)
|
|
29
|
-
@out.puts(string)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def print(string)
|
|
33
|
-
@out.print(string)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
32
|
def format_plan(plan) # rubocop:disable Metrics/PerceivedComplexity
|
|
37
|
-
|
|
33
|
+
logger.info "API validation coverage for #{plan.api_identifier}: #{plan.coverage}%"
|
|
38
34
|
return if plan.done? && !verbose
|
|
39
35
|
|
|
40
36
|
requested_routes_count = plan.routes.count { |route| route.requests.any?(&:requested?) }
|
|
@@ -54,9 +50,9 @@ module OpenapiFirst
|
|
|
54
50
|
def format_requests(requests)
|
|
55
51
|
requests.each do |request|
|
|
56
52
|
if request.finished?
|
|
57
|
-
|
|
53
|
+
log_success "✓ #{request_label(request)}"
|
|
58
54
|
else
|
|
59
|
-
|
|
55
|
+
log_error "❌ #{request_label(request)} – #{explain_unfinished_request(request)}"
|
|
60
56
|
end
|
|
61
57
|
end
|
|
62
58
|
end
|
|
@@ -64,25 +60,13 @@ module OpenapiFirst
|
|
|
64
60
|
def format_responses(responses)
|
|
65
61
|
responses.each do |response|
|
|
66
62
|
if response.finished?
|
|
67
|
-
|
|
63
|
+
log_success " ✓ #{response_label(response)}" if verbose
|
|
68
64
|
else
|
|
69
|
-
|
|
65
|
+
log_error " ❌ #{response_label(response)} – #{explain_unfinished_response(response)}"
|
|
70
66
|
end
|
|
71
67
|
end
|
|
72
68
|
end
|
|
73
69
|
|
|
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
70
|
def request_label(request)
|
|
87
71
|
name = "#{request.request_method.upcase} #{request.path}" # TODO: add required query parameters?
|
|
88
72
|
name << " (#{request.content_type})" if request.content_type
|
|
@@ -109,6 +93,14 @@ module OpenapiFirst
|
|
|
109
93
|
|
|
110
94
|
"All responses invalid! (#{response.last_error_message.inspect})" unless response.any_valid_response?
|
|
111
95
|
end
|
|
96
|
+
|
|
97
|
+
def log_success(msg)
|
|
98
|
+
logger.info "\e[32m#{msg}\e[0m"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def log_error(msg)
|
|
102
|
+
logger.error "\e[31m#{msg}\e[0m"
|
|
103
|
+
end
|
|
112
104
|
end
|
|
113
105
|
end
|
|
114
106
|
end
|
|
@@ -12,7 +12,11 @@ module OpenapiFirst
|
|
|
12
12
|
# to assess if all parts of the API description have been tested.
|
|
13
13
|
# Currently it does not care about unknown requests that are not part of any API description.
|
|
14
14
|
module Coverage
|
|
15
|
-
autoload :
|
|
15
|
+
autoload :TerminalReporter, 'openapi_first/test/coverage/terminal_reporter'
|
|
16
|
+
autoload :HtmlReporter, 'openapi_first/test/coverage/html_reporter'
|
|
17
|
+
|
|
18
|
+
# @deprecated Use {TerminalReporter} instead.
|
|
19
|
+
TerminalFormatter = TerminalReporter
|
|
16
20
|
|
|
17
21
|
Result = Data.define(:plans, :coverage)
|
|
18
22
|
|
|
@@ -45,7 +49,11 @@ module OpenapiFirst
|
|
|
45
49
|
# the coverage collection.
|
|
46
50
|
# To make this work we need to keep arguments trivial, which is the reason the request
|
|
47
51
|
# is wrapped in a CoveredRequest data object.
|
|
48
|
-
|
|
52
|
+
# :nocov:
|
|
53
|
+
return unless tracker
|
|
54
|
+
# :nocov:
|
|
55
|
+
|
|
56
|
+
tracker.track_request(
|
|
49
57
|
oad.key,
|
|
50
58
|
CoveredRequest.new(
|
|
51
59
|
key: request.request_definition.key,
|
|
@@ -61,7 +69,11 @@ module OpenapiFirst
|
|
|
61
69
|
# the coverage collection.
|
|
62
70
|
# To make this work we need to keep arguments trivial, which is the reason the response
|
|
63
71
|
# is wrapped in a CoveredResponse data object.
|
|
64
|
-
|
|
72
|
+
# :nocov:
|
|
73
|
+
return unless tracker
|
|
74
|
+
# :nocov:
|
|
75
|
+
|
|
76
|
+
tracker.track_response(
|
|
65
77
|
oad.key,
|
|
66
78
|
CoveredResponse.new(
|
|
67
79
|
key: response.response_definition.key,
|
|
@@ -76,10 +88,6 @@ module OpenapiFirst
|
|
|
76
88
|
|
|
77
89
|
private
|
|
78
90
|
|
|
79
|
-
def current_run
|
|
80
|
-
tracker.plans_by_key
|
|
81
|
-
end
|
|
82
|
-
|
|
83
91
|
# Returns all plans (Plan) that were registered for this run
|
|
84
92
|
def plans
|
|
85
93
|
tracker&.plans || []
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module OpenapiFirst
|
|
6
|
+
module Test
|
|
7
|
+
# Logger to output coverage reports and such
|
|
8
|
+
class Logger < ::Logger
|
|
9
|
+
def initialize(*)
|
|
10
|
+
super
|
|
11
|
+
self.formatter = proc { |_severity, _time, _progname, msg|
|
|
12
|
+
"#{msg}\n"
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|