openapi_first 3.4.2 → 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 +70 -0
  3. data/README.md +95 -42
  4. data/lib/openapi_first/builder.rb +87 -52
  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 +12 -18
  21. data/lib/openapi_first/request_body_parsers.rb +19 -16
  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
@@ -38,30 +38,6 @@ module OpenapiFirst
38
38
  :ignore_unknown_requests, :ignore_unknown_response_status, :minimum_coverage, :logger
39
39
  attr_reader :report_coverage, :ignored_unknown_status
40
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
-
65
41
  # Set ignored unknown status codes.
66
42
  # @param [Array<Integer>] status Status codes that are okay not to cover in an OAD
67
43
  def ignored_unknown_status=(status)
@@ -130,16 +106,6 @@ module OpenapiFirst
130
106
 
131
107
  true
132
108
  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
143
109
  end
144
110
  end
145
111
  end
@@ -12,12 +12,16 @@ module OpenapiFirst
12
12
  'API Coverage did not detect any API requests for the registered ' \
13
13
  'API descriptions. Make sure to observe your application using OpenapiFirst::Test.'
14
14
 
15
- attr_reader :coverage, :plans, :verbose
15
+ attr_reader :coverage, :plans, :verbose, :generated_at,
16
+ :skipped_requests_count, :skipped_responses_count
16
17
 
17
- def initialize(coverage_result, verbose)
18
+ def initialize(coverage_result, verbose, generated_at: Time.now)
18
19
  @coverage = coverage_result.coverage
19
20
  @plans = coverage_result.plans
21
+ @skipped_requests_count = coverage_result.skipped_requests_count
22
+ @skipped_responses_count = coverage_result.skipped_responses_count
20
23
  @verbose = verbose
24
+ @generated_at = generated_at.strftime('%Y-%m-%d %H:%M:%S %z')
21
25
  end
22
26
 
23
27
  # Helper for ERB rendering only — exposes this context's binding so the
@@ -26,14 +30,12 @@ module OpenapiFirst
26
30
  binding
27
31
  end
28
32
 
29
- def expand_plan?(plan)
30
- verbose || plan.done?
33
+ def any_skipped?
34
+ skipped_requests_count.positive? || skipped_responses_count.positive?
31
35
  end
32
36
 
33
37
  def visible_routes(plan)
34
- return plan.routes if expand_plan?(plan)
35
-
36
- plan.routes.reject(&:finished?)
38
+ plan.routes
37
39
  end
38
40
 
39
41
  def any_request_made?(route)
@@ -41,27 +43,32 @@ module OpenapiFirst
41
43
  end
42
44
 
43
45
  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
+ return :skipped if route.skipped?
47
+ return :request_problem if route.tracked_requests.none?(&:finished?)
48
+ return :responses_problem if any_request_made?(route) && route.tracked_responses.any? { |r| !r.finished? }
46
49
 
47
50
  :ok
48
51
  end
49
52
 
53
+ def route_class(route)
54
+ return 'is-skipped' if route.skipped?
55
+
56
+ route.finished? ? 'is-covered' : 'is-uncovered'
57
+ end
58
+
50
59
  def uncovered_responses_count(route)
51
- route.responses.count { |r| !r.finished? }
60
+ route.tracked_responses.count { |r| !r.finished? }
52
61
  end
53
62
 
54
- def request_items(route, plan_verbose:)
63
+ def request_items(route)
64
+ return route.requests if route.skipped?
55
65
  return [] unless any_request_made?(route) && route.requests.any?(&:content_type)
56
66
 
57
- plan_verbose ? route.requests : route.requests.reject(&:finished?)
67
+ route.requests
58
68
  end
59
69
 
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
- []
70
+ def response_items(route)
71
+ route.responses
65
72
  end
66
73
 
67
74
  def h(text)
@@ -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