openapi_first 3.4.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +95 -42
  4. data/lib/openapi_first/builder.rb +85 -50
  5. data/lib/openapi_first/child_configuration.rb +0 -2
  6. data/lib/openapi_first/configuration.rb +0 -23
  7. data/lib/openapi_first/definition.rb +35 -2
  8. data/lib/openapi_first/failure.rb +5 -1
  9. data/lib/openapi_first/middlewares/request_validation.rb +1 -1
  10. data/lib/openapi_first/middlewares/response_validation.rb +1 -1
  11. data/lib/openapi_first/parameter/converter/array_converter.rb +42 -0
  12. data/lib/openapi_first/parameter/converter/object_converter.rb +60 -0
  13. data/lib/openapi_first/parameter/converter.rb +69 -0
  14. data/lib/openapi_first/parameter/unpackers.rb +132 -0
  15. data/lib/openapi_first/parameter.rb +70 -0
  16. data/lib/openapi_first/parameter_content_parsers.rb +55 -0
  17. data/lib/openapi_first/parameters_parser.rb +23 -0
  18. data/lib/openapi_first/query_string_parser.rb +93 -0
  19. data/lib/openapi_first/ref_resolver.rb +56 -3
  20. data/lib/openapi_first/request.rb +11 -10
  21. data/lib/openapi_first/request_body_parsers.rb +11 -7
  22. data/lib/openapi_first/request_headers.rb +27 -0
  23. data/lib/openapi_first/request_validator.rb +4 -1
  24. data/lib/openapi_first/response_header.rb +9 -0
  25. data/lib/openapi_first/response_parser.rb +3 -10
  26. data/lib/openapi_first/router.rb +27 -12
  27. data/lib/openapi_first/schema/hash.rb +0 -1
  28. data/lib/openapi_first/sinatra.rb +217 -0
  29. data/lib/openapi_first/test/configuration.rb +0 -34
  30. data/lib/openapi_first/test/coverage/html_reporter/context.rb +24 -17
  31. data/lib/openapi_first/test/coverage/html_reporter.css +214 -67
  32. data/lib/openapi_first/test/coverage/html_reporter.html.erb +39 -11
  33. data/lib/openapi_first/test/coverage/html_reporter.rb +11 -1
  34. data/lib/openapi_first/test/coverage/plan.rb +30 -10
  35. data/lib/openapi_first/test/coverage/request_task.rb +7 -2
  36. data/lib/openapi_first/test/coverage/response_task.rb +6 -1
  37. data/lib/openapi_first/test/coverage/route_task.rb +23 -1
  38. data/lib/openapi_first/test/coverage/skipped_summary.rb +22 -0
  39. data/lib/openapi_first/test/coverage/terminal_reporter.rb +23 -11
  40. data/lib/openapi_first/test/coverage.rb +9 -3
  41. data/lib/openapi_first/test.rb +69 -11
  42. data/lib/openapi_first/validators/multipart_request_body.rb +57 -0
  43. data/lib/openapi_first/validators/request_body.rb +20 -7
  44. data/lib/openapi_first/validators/request_parameters.rb +5 -4
  45. data/lib/openapi_first/version.rb +1 -1
  46. metadata +15 -23
  47. data/lib/openapi_first/header.rb +0 -9
@@ -1,41 +1,162 @@
1
- *, *::before, *::after { box-sizing: border-box; }
1
+ *,
2
+ *::before,
3
+ *::after {
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ :root {
8
+ --font-size-base: 1rem;
9
+ --font-size-xs: calc(var(--font-size-base) * 0.75);
10
+ --font-size-sm: calc(var(--font-size-base) * 0.875);
11
+ --font-size-h1: calc(var(--font-size-base) * 1.6);
12
+
13
+ --bg-primary: #0f1117;
14
+ --bg-secondary: #161b27;
15
+
16
+ --border-default: #1e293b;
17
+ --border-active: #334155;
18
+ --border-warning: #3b3010;
19
+
20
+ --tree-indent: 1.25rem;
21
+ --tree-row-center: 0.9rem;
22
+
23
+ --text-heading: #f8fafc;
24
+ --text-primary: #e2e8f0;
25
+ --text-muted: #94a3b8;
26
+
27
+ --color-success: #4ade80;
28
+ --color-error: #f87171;
29
+ --color-info: #7dd3fc;
30
+ --color-warning: #fbbf24;
31
+ --color-skipped: #fb923c;
32
+ }
2
33
 
3
34
  body {
4
- font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
5
- background: #0f1117;
6
- color: #e2e8f0;
35
+ font-family:
36
+ ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
37
+ background: var(--bg-primary);
38
+ color: var(--text-primary);
7
39
  margin: 0;
8
- padding: 2rem;
40
+ padding: 1rem 2rem;
9
41
  line-height: 1.6;
10
42
  }
11
43
 
44
+ .page-header {
45
+ display: flex;
46
+ align-items: baseline;
47
+ justify-content: space-between;
48
+ gap: 1rem;
49
+ margin-block-end: 0.875rem;
50
+ }
51
+
52
+ .generated-at {
53
+ font-size: var(--font-size-xs);
54
+ color: var(--text-muted);
55
+ white-space: nowrap;
56
+ }
57
+
58
+ .page-title {
59
+ display: flex;
60
+ align-items: center;
61
+ align-self: stretch;
62
+ margin: 0;
63
+ }
64
+
12
65
  h1 {
13
- font-size: 1.1rem;
14
- font-weight: 600;
15
- margin-block: 0 1rem;
16
- color: #f8fafc;
66
+ font-size: var(--font-size-h1);
67
+ color: var(--text-heading);
17
68
 
18
69
  & .pct {
19
- font-size: 0.95rem;
20
- font-weight: 400;
21
- color: #94a3b8;
70
+ font-size: var(--font-size-sm);
71
+ color: var(--text-muted);
22
72
  margin-inline-start: 0.5rem;
23
73
  }
24
74
  }
25
75
 
76
+ h2 {
77
+ margin: 0;
78
+ font-size: var(--font-size-sm);
79
+ color: var(--text-heading);
80
+ }
81
+
82
+ .api-identifier {
83
+ font-size: var(--font-size-xs);
84
+ color: var(--text-muted);
85
+ font-weight: normal;
86
+ }
87
+
88
+ .filters {
89
+ display: flex;
90
+ justify-content: flex-end;
91
+ gap: 1.25rem;
92
+ margin-block-end: 0.5rem;
93
+ font-size: var(--font-size-sm);
94
+ }
95
+
96
+ .filters .filter {
97
+ display: inline-flex;
98
+ align-items: center;
99
+ gap: 0.4rem;
100
+ cursor: pointer;
101
+ color: var(--text-muted);
102
+ user-select: none;
103
+
104
+ & input {
105
+ cursor: pointer;
106
+ }
107
+ }
108
+
109
+ /* CSS-only route filtering driven by the toolbar checkboxes. */
110
+ body:has(#filter-covered:not(:checked)) details.route.is-covered {
111
+ display: none;
112
+ }
113
+ body:has(#filter-uncovered:not(:checked)) details.route.is-uncovered {
114
+ display: none;
115
+ }
116
+ body:has(#filter-skipped-requests:not(:checked)) details.route.is-skipped {
117
+ display: none;
118
+ }
119
+
120
+ /* Hide a plan once none of its routes remain visible. */
121
+ body:has(#filter-covered:not(:checked))
122
+ details.plan:not(:has(details.route.is-uncovered)):not(
123
+ :has(details.route.is-skipped)
124
+ ),
125
+ body:has(#filter-uncovered:not(:checked))
126
+ details.plan:not(:has(details.route.is-covered)):not(
127
+ :has(details.route.is-skipped)
128
+ ),
129
+ body:has(#filter-skipped-requests:not(:checked))
130
+ details.plan:not(:has(details.route.is-covered)):not(
131
+ :has(details.route.is-uncovered)
132
+ ),
133
+ body:has(#filter-covered:not(:checked)):has(#filter-uncovered:not(:checked))
134
+ details.plan:not(:has(details.route.is-skipped)),
135
+ body:has(#filter-covered:not(:checked)):has(
136
+ #filter-skipped-requests:not(:checked)
137
+ )
138
+ details.plan:not(:has(details.route.is-uncovered)),
139
+ body:has(#filter-uncovered:not(:checked)):has(
140
+ #filter-skipped-requests:not(:checked)
141
+ )
142
+ details.plan:not(:has(details.route.is-covered)),
143
+ body:has(#filter-covered:not(:checked)):has(
144
+ #filter-uncovered:not(:checked)
145
+ ):has(#filter-skipped-requests:not(:checked))
146
+ details.plan {
147
+ display: none;
148
+ }
149
+
26
150
  details.plan {
27
- border: 1px solid #1e293b;
151
+ border: 1px solid var(--border-default);
28
152
  padding: 1.25rem 1.5rem;
29
153
  margin-block-end: 1.5rem;
30
- background: #161b27;
31
-
32
- &[open] { border-color: #334155; }
154
+ background: var(--bg-secondary);
33
155
  }
34
156
 
35
157
  details.plan > summary {
36
- font-size: 1rem;
37
- font-weight: 600;
38
- color: #cbd5e1;
158
+ font-size: var(--font-size-base);
159
+ color: var(--text-heading);
39
160
  cursor: pointer;
40
161
  list-style: none;
41
162
  display: flex;
@@ -44,41 +165,50 @@ details.plan > summary {
44
165
  user-select: none;
45
166
 
46
167
  & .pct {
47
- font-size: 0.8rem;
48
- font-weight: 400;
49
- color: #64748b;
168
+ font-size: var(--font-size-xs);
169
+ color: var(--text-muted);
50
170
  text-transform: none;
51
171
  letter-spacing: 0;
52
172
  }
53
173
 
54
174
  &::before {
55
- content: '';
56
- font-size: 0.6rem;
57
- color: #475569;
175
+ content: "";
176
+ font-size: var(--font-size-xs);
177
+ color: var(--text-muted);
58
178
  transition: transform 0.15s ease;
59
179
  align-self: center;
60
180
  }
61
181
 
62
- details[open] > &::before { transform: rotate(90deg); }
182
+ details[open] > &::before {
183
+ transform: rotate(90deg);
184
+ }
63
185
  }
64
186
 
65
187
  .plan-content {
66
- margin-block-start: 1rem;
188
+ margin-block-start: 0.75rem;
189
+ margin-inline-start: 0.25rem;
190
+ padding-inline-start: var(--tree-indent);
191
+ border-inline-start: 2px solid var(--border-default);
67
192
  }
68
193
 
69
194
  details.route {
70
- border-inline-start: 2px solid #1e293b;
71
- padding-inline-start: 1rem;
72
- margin-block: 0.75rem;
195
+ margin-block: 0;
196
+ position: relative;
73
197
 
74
- &[open] { border-color: #334155; }
198
+ /* horizontal connector reaching left to the plan spine */
199
+ &::before {
200
+ content: "";
201
+ position: absolute;
202
+ left: calc(-1 * var(--tree-indent));
203
+ top: var(--tree-row-center);
204
+ width: calc(var(--tree-indent) - 0.25rem);
205
+ border-top: 2px solid var(--border-default);
206
+ }
75
207
  }
76
208
 
77
209
  details.route > summary {
78
- font-weight: 600;
79
- color: #94a3b8;
80
- font-size: 0.85rem;
81
- text-transform: uppercase;
210
+ color: var(--text-muted);
211
+ font-size: var(--font-size-sm);
82
212
  letter-spacing: 0.04em;
83
213
  cursor: pointer;
84
214
  list-style: none;
@@ -89,27 +219,27 @@ details.route > summary {
89
219
  user-select: none;
90
220
 
91
221
  &::before {
92
- content: '';
93
- font-size: 0.6rem;
94
- color: #475569;
222
+ content: "";
223
+ font-size: var(--font-size-xs);
224
+ color: var(--text-muted);
95
225
  transition: transform 0.15s ease;
96
226
  }
97
227
 
98
- details[open] > &::before { transform: rotate(90deg); }
228
+ details[open] > &::before {
229
+ transform: rotate(90deg);
230
+ }
99
231
  }
100
232
 
101
233
  .operation-label {
102
- font-size: 0.8rem;
103
- font-weight: 400;
104
- color: #64748b;
234
+ font-size: var(--font-size-xs);
235
+ color: var(--text-muted);
105
236
  text-transform: none;
106
237
  letter-spacing: 0;
107
238
  }
108
239
 
109
240
  .request-status,
110
241
  .response-summary {
111
- font-size: 0.8rem;
112
- font-weight: 400;
242
+ font-size: var(--font-size-xs);
113
243
  text-transform: none;
114
244
  letter-spacing: 0;
115
245
  }
@@ -121,59 +251,76 @@ details.route > summary {
121
251
  ul.tasks {
122
252
  list-style: none;
123
253
  margin: 0;
124
- padding: 0;
254
+ padding-inline-start: var(--tree-indent);
255
+ padding-block: 0;
125
256
  display: flex;
126
257
  flex-direction: column;
127
- gap: 0.2rem;
128
- }
129
-
130
- ul.responses {
131
- padding-inline-start: 1.5rem;
258
+ border-inline-start: 2px solid var(--border-default);
132
259
  }
133
260
 
134
261
  li {
135
- font-size: 0.875rem;
262
+ font-size: var(--font-size-sm);
136
263
  padding: 0.2rem 0.4rem;
137
264
  display: flex;
138
265
  align-items: baseline;
139
266
  gap: 0.5rem;
267
+ position: relative;
268
+
269
+ /* horizontal connector reaching left to the task spine */
270
+ &::before {
271
+ content: "";
272
+ position: absolute;
273
+ left: calc(-1 * var(--tree-indent));
274
+ top: var(--tree-row-center);
275
+ width: calc(var(--tree-indent) - 0.25rem);
276
+ border-top: 2px solid var(--border-default);
277
+ }
278
+ }
279
+
280
+ .covered {
281
+ color: var(--color-success);
140
282
  }
141
283
 
142
- .covered { color: #4ade80; }
284
+ .skipped {
285
+ color: var(--color-skipped);
286
+ }
287
+
288
+ .skipped-note,
289
+ .skipped-summary {
290
+ color: var(--color-skipped);
291
+ font-size: var(--font-size-xs);
292
+ }
143
293
 
144
- .uncovered {
145
- background: #1f1315;
146
- font-weight: 500;
294
+ .skipped-summary {
295
+ margin-inline-start: 0.75rem;
296
+ white-space: nowrap;
147
297
  }
148
298
 
149
299
  .status {
150
- color: #e2e8f0;
151
- font-weight: 600;
300
+ color: var(--text-primary);
152
301
  min-width: 3ch;
153
302
  }
154
303
 
155
304
  .content-type {
156
- color: #64748b;
157
- font-size: 0.8rem;
305
+ color: var(--text-muted);
306
+ font-size: var(--font-size-xs);
158
307
  }
159
308
 
160
309
  .problem {
161
- color: #f87171;
162
- font-size: 0.8rem;
310
+ color: var(--color-error);
311
+ font-size: var(--font-size-xs);
163
312
  }
164
313
 
165
314
  .method {
166
- color: #7dd3fc;
167
- font-weight: 700;
315
+ color: var(--color-info);
168
316
  }
169
317
 
170
318
  .path {
171
- color: #e2e8f0;
319
+ color: var(--text-primary);
172
320
  }
173
321
 
174
322
  .warning {
175
- color: #fbbf24;
176
- background: #1c1a10;
177
- border: 1px solid #3b3010;
323
+ color: var(--color-warning);
324
+ border: 1px solid var(--border-warning);
178
325
  padding: 0.75rem 1rem;
179
326
  }
@@ -6,41 +6,64 @@
6
6
  <style><%= File.read(File.join(__dir__, 'html_reporter.css')) %></style>
7
7
  </head>
8
8
  <body>
9
- <h1>API Coverage <span class="pct"><%= coverage.round(1) %>%</span></h1>
9
+ <header class="page-header">
10
+ <h1 class="page-title">API Coverage: <%= coverage.round(1) %>%</h1>
11
+ <div>
12
+ <span class="generated-at">Generated at: <%= h generated_at %></span>
13
+ <% if any_skipped? -%>
14
+ <span class="skipped-summary"><%= skipped_requests_count %> request(s) and <%= skipped_responses_count %> response(s) skipped</span>
15
+ <% end -%>
16
+ <div class="filters">
17
+ <label class="filter"><input type="checkbox" id="filter-covered" checked> Covered operations</label>
18
+ <label class="filter"><input type="checkbox" id="filter-uncovered" checked> Uncovered operations</label>
19
+ <label class="filter"><input type="checkbox" id="filter-skipped-requests" checked> Skipped requests</label>
20
+ </div>
21
+ </div>
22
+ </header>
10
23
  <% if plans.empty? -%>
11
24
  <p class="warning"><%= NO_REQUESTS_WARNING %></p>
12
25
  <% else -%>
13
26
  <% plans.each do |plan| -%>
14
27
  <details class="plan"<%= ' open' unless plan.done? %>>
15
28
  <summary>
16
- <%= h plan.title || plan.api_identifier %>
17
- <span class="pct"><%= plan.api_identifier %> · <%= plan.coverage.round(1) %>%</span>
29
+ <h2><%= h plan.title || plan.api_identifier %> <span class="api-identifier"><%= h plan.api_identifier %></span></h2>
30
+ <span class="pct"><%= plan.coverage.round(1) %>%</span>
18
31
  </summary>
19
32
  <div class="plan-content">
20
- <% plan_verbose = expand_plan?(plan) -%>
21
33
  <% visible_routes(plan).each do |route| -%>
22
- <details class="route"<%= ' open' unless route.finished? %>>
34
+ <% requests = request_items(route) -%>
35
+ <% responses = response_items(route) -%>
36
+ <details class="route <%= route_class(route) %>"<%= ' open' if any_request_made?(route) && !route.finished? %>>
23
37
  <summary>
24
38
  <span class="method"><%= h route.request_method.upcase %></span>
25
39
  <span class="path"><%= h route.path %></span>
26
40
  <% 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>
41
+ <% if status == :skipped -%>
42
+ <span class="request-status skipped">Skipped</span>
43
+ <% elsif status == :request_problem -%>
44
+ <span class="request-status problem">❌ <span class="problem"><%= h explain_unfinished_request(route.tracked_requests.first) %></span></span>
29
45
  <% elsif status == :responses_problem -%>
30
46
  <span class="response-summary problem">⚠️ <%= uncovered_responses_count(route) %> response(s) not covered</span>
31
47
  <% else -%>
32
48
  <span class="request-status covered">✅</span>
33
49
  <% end -%>
50
+ <% if route.skipped_responses.any? && status != :skipped -%>
51
+ <span class="response-summary skipped"><%= route.skipped_responses.count %> response(s) skipped</span>
52
+ <% end -%>
34
53
  <% if (label = route.summary) -%>
35
54
  <span class="operation-label"><%= h label %></span>
36
55
  <% end -%>
37
56
  </summary>
38
57
  <div class="route-content">
39
- <% requests = request_items(route, plan_verbose: plan_verbose) -%>
40
58
  <% unless requests.empty? -%>
41
59
  <ul class="tasks">
42
60
  <% requests.each do |request| -%>
43
- <% if request.finished? -%>
61
+ <% if request.skipped? -%>
62
+ <li class="skipped">
63
+ <% if request.content_type -%><span class="content-type"><%= h request.content_type %></span><% end -%>
64
+ <span class="skipped-note">Skipped, not covered by any test</span>
65
+ </li>
66
+ <% elsif request.finished? -%>
44
67
  <li class="covered">
45
68
  <span>✅</span>
46
69
  <% if request.content_type -%><span class="content-type"><%= h request.content_type %></span><% end -%>
@@ -55,11 +78,16 @@
55
78
  <% end -%>
56
79
  </ul>
57
80
  <% end -%>
58
- <% responses = response_items(route, plan_verbose: plan_verbose) -%>
59
81
  <% unless responses.empty? -%>
60
82
  <ul class="tasks responses">
61
83
  <% responses.each do |response| -%>
62
- <% if response.finished? -%>
84
+ <% if response.skipped? -%>
85
+ <li class="skipped">
86
+ <span class="status"><%= h response.status %></span>
87
+ <% if response.content_type -%><span class="content-type"><%= h response.content_type %></span><% end -%>
88
+ <span class="skipped-note">Skipped</span>
89
+ </li>
90
+ <% elsif response.finished? -%>
63
91
  <li class="covered">
64
92
  <span>✅</span>
65
93
  <span class="status"><%= h response.status %></span>
@@ -8,6 +8,8 @@ module OpenapiFirst
8
8
  module Coverage
9
9
  # Writes a self-contained HTML coverage report to a file.
10
10
  class HtmlReporter
11
+ include SkippedSummary
12
+
11
13
  def initialize(output: 'coverage/openapi_coverage.html', verbose: false, logger: Test.logger)
12
14
  @output = output
13
15
  @verbose = verbose
@@ -18,9 +20,17 @@ module OpenapiFirst
18
20
  html = TEMPLATE.result(Context.new(coverage_result, @verbose).get_binding)
19
21
  FileUtils.mkdir_p(File.dirname(@output))
20
22
  File.write(@output, html)
21
- @logger.info "API coverage report written to #{@output}"
23
+ coverage_result.plans.each do |plan|
24
+ next if plan.coverage >= 100
25
+
26
+ logger.info "API validation coverage for #{plan.api_identifier}: #{plan.coverage.round(4)}%"
27
+ end
28
+ log_skipped_summary(coverage_result)
29
+ logger.info "API coverage report written to #{@output}"
22
30
  end
23
31
 
32
+ private attr_reader :logger
33
+
24
34
  TEMPLATE_PATH = File.join(__dir__, 'html_reporter.html.erb')
25
35
  TEMPLATE = ERB.new(File.read(TEMPLATE_PATH), trim_mode: '-')
26
36
  TEMPLATE.filename = TEMPLATE_PATH
@@ -14,14 +14,13 @@ module OpenapiFirst
14
14
 
15
15
  def self.for(oad, skip_response: nil, skip_route: nil)
16
16
  plan = new(definition_key: oad.key, filepath: oad.filepath, title: oad.title)
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
17
+ oad.routes.each do |route|
21
18
  plan.add_route request_method: route.request_method,
22
19
  path: route.path,
23
20
  requests: route.requests,
24
- responses:
21
+ responses: route.responses,
22
+ skipped: skip_route ? skip_route[route.path, route.request_method] : false,
23
+ skip_response:
25
24
  end
26
25
  plan
27
26
  end
@@ -63,15 +62,36 @@ module OpenapiFirst
63
62
  index.values
64
63
  end
65
64
 
66
- def add_route(request_method:, path:, requests:, responses:)
67
- request_tasks = requests.to_a.map do |request|
68
- index[request.key] = RequestTask.new(request)
69
- end
65
+ def skipped_requests
66
+ routes.flat_map(&:skipped_requests)
67
+ end
68
+
69
+ def skipped_responses
70
+ routes.flat_map(&:skipped_responses)
71
+ end
72
+
73
+ def skipped_requests_count
74
+ skipped_requests.count
75
+ end
76
+
77
+ def skipped_responses_count
78
+ skipped_responses.count
79
+ end
80
+
81
+ def add_route(request_method:, path:, requests:, responses:, skipped: false, skip_response: nil)
82
+ request_tasks = requests.to_a.map { |request| add_task RequestTask.new(request, skipped:) }
70
83
  response_tasks = responses.to_a.map do |response|
71
- index[response.key] = ResponseTask.new(response)
84
+ add_task ResponseTask.new(response, skipped: skipped || (skip_response ? skip_response[response] : false))
72
85
  end
73
86
  @routes << RouteTask.new(path:, request_method:, requests: request_tasks, responses: response_tasks)
74
87
  end
88
+
89
+ private
90
+
91
+ def add_task(task)
92
+ index[task.key] = task unless task.skipped?
93
+ task
94
+ end
75
95
  end
76
96
  end
77
97
  end
@@ -9,10 +9,11 @@ module OpenapiFirst
9
9
  class RequestTask
10
10
  extend Forwardable
11
11
 
12
- def_delegators :@request, :path, :request_method, :content_type
12
+ def_delegators :@request, :key, :path, :request_method, :content_type
13
13
 
14
- def initialize(request_definition)
14
+ def initialize(request_definition, skipped: false)
15
15
  @request = request_definition
16
+ @skipped = skipped
16
17
  @requested = false
17
18
  @last_error_message = nil
18
19
  end
@@ -25,6 +26,10 @@ module OpenapiFirst
25
26
  @last_error_message = validated_request.error.exception_message unless validated_request.valid?
26
27
  end
27
28
 
29
+ def skipped?
30
+ @skipped == true
31
+ end
32
+
28
33
  def requested?
29
34
  @requested == true
30
35
  end
@@ -11,8 +11,9 @@ module OpenapiFirst
11
11
 
12
12
  def_delegators :@response, :status, :content_type, :key
13
13
 
14
- def initialize(response_definition)
14
+ def initialize(response_definition, skipped: false)
15
15
  @response = response_definition
16
+ @skipped = skipped
16
17
  @responded = false
17
18
  @last_error_message = nil
18
19
  end
@@ -25,6 +26,10 @@ module OpenapiFirst
25
26
  @last_error_message = validated_response.error.exception_message unless validated_response.valid?
26
27
  end
27
28
 
29
+ def skipped?
30
+ @skipped == true
31
+ end
32
+
28
33
  def responded?
29
34
  @responded == true
30
35
  end
@@ -4,8 +4,30 @@ module OpenapiFirst
4
4
  module Test
5
5
  module Coverage
6
6
  RouteTask = Data.define(:path, :request_method, :requests, :responses) do
7
+ def skipped?
8
+ requests.all?(&:skipped?)
9
+ end
10
+
11
+ def tracked_requests
12
+ requests.reject(&:skipped?)
13
+ end
14
+
15
+ def tracked_responses
16
+ responses.reject(&:skipped?)
17
+ end
18
+
19
+ def skipped_requests
20
+ requests.select(&:skipped?)
21
+ end
22
+
23
+ def skipped_responses
24
+ responses.select(&:skipped?)
25
+ end
26
+
7
27
  def finished?
8
- requests.all?(&:finished?) && responses.all?(&:finished?)
28
+ return false if skipped?
29
+
30
+ tracked_requests.all?(&:finished?) && tracked_responses.all?(&:finished?)
9
31
  end
10
32
 
11
33
  def summary
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ module Coverage
6
+ # Shared reporting of skipped requests and responses for coverage reporters.
7
+ module SkippedSummary
8
+ def log_skipped_summary(coverage_result)
9
+ requests = coverage_result.skipped_requests_count
10
+ responses = coverage_result.skipped_responses_count
11
+ return if requests.zero? && responses.zero?
12
+
13
+ logger.info "API coverage skipped #{requests} request(s) and #{responses} response(s)."
14
+ return if requests.zero?
15
+
16
+ logger.warn "#{requests} request(s) were skipped entirely. " \
17
+ 'These requests are excluded from API coverage and are not contract-tested at all.'
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end