rspec-openapi 0.35.0 → 0.36.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.
- checksums.yaml +4 -4
- data/README.md +101 -0
- data/lib/rspec/openapi/components_updater.rb +16 -9
- data/lib/rspec/openapi/extractors/shared_extractor.rb +42 -6
- data/lib/rspec/openapi/hash_helper.rb +42 -3
- data/lib/rspec/openapi/key_transformer.rb +17 -7
- data/lib/rspec/openapi/minitest_hooks.rb +2 -1
- data/lib/rspec/openapi/parallel_records.rb +59 -4
- data/lib/rspec/openapi/partial_run.rb +84 -0
- data/lib/rspec/openapi/record.rb +2 -0
- data/lib/rspec/openapi/result_recorder.rb +21 -8
- data/lib/rspec/openapi/rspec_hooks.rb +11 -3
- data/lib/rspec/openapi/schema_builder/build_context.rb +18 -4
- data/lib/rspec/openapi/schema_builder/variation_merger.rb +28 -9
- data/lib/rspec/openapi/schema_builder.rb +91 -58
- data/lib/rspec/openapi/schema_cleaner.rb +73 -56
- data/lib/rspec/openapi/schema_file.rb +27 -20
- data/lib/rspec/openapi/schema_merger.rb +22 -1
- data/lib/rspec/openapi/schema_sorter.rb +24 -4
- data/lib/rspec/openapi/version.rb +1 -1
- data/lib/rspec/openapi/yaml_dumper.rb +103 -0
- data/lib/rspec/openapi/yaml_loader.rb +118 -0
- data/lib/rspec/openapi.rb +14 -1
- metadata +5 -2
|
@@ -10,16 +10,24 @@ RSpec.configuration.after(:each) do |example|
|
|
|
10
10
|
if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false
|
|
11
11
|
path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
|
|
12
12
|
record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor)
|
|
13
|
-
|
|
13
|
+
if record
|
|
14
|
+
RSpec::OpenAPI.path_records[path] << record
|
|
15
|
+
RSpec::OpenAPI::PartialRun.record_file(example.metadata[:absolute_file_path])
|
|
16
|
+
end
|
|
14
17
|
end
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
RSpec.configuration.after(:suite) do
|
|
18
|
-
|
|
21
|
+
partial = RSpec::OpenAPI::PartialRun.rspec?
|
|
22
|
+
result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records, partial: partial)
|
|
19
23
|
result_recorder.record_results!
|
|
24
|
+
reporter = RSpec.configuration.reporter
|
|
25
|
+
if (notice = RSpec::OpenAPI::PartialRun.notice(partial))
|
|
26
|
+
reporter.message notice
|
|
27
|
+
end
|
|
20
28
|
if result_recorder.errors?
|
|
21
29
|
error_message = result_recorder.error_message
|
|
22
30
|
colorizer = RSpec::Core::Formatters::ConsoleCodes
|
|
23
|
-
|
|
31
|
+
reporter.message colorizer.wrap(error_message, :failure)
|
|
24
32
|
end
|
|
25
33
|
end
|
|
@@ -6,14 +6,28 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
6
6
|
# a value at a given path under a record's request/response side.
|
|
7
7
|
#
|
|
8
8
|
# Positional members: one is built per property of every body, and
|
|
9
|
-
# keyword_init would add a keyword hash to each.
|
|
10
|
-
|
|
9
|
+
# keyword_init would add a keyword hash to each. The path is only ever looked
|
|
10
|
+
# up in the enum and additionalProperties maps of the record, so it is built
|
|
11
|
+
# for a side of a record that holds one.
|
|
12
|
+
BuildContext = Struct.new(:record, :context, :path, :key, :lookups) do
|
|
13
|
+
def self.root(record, context, path = nil, key = nil)
|
|
14
|
+
lookups = record[ENUM_MEMBERS.fetch(context)] ||
|
|
15
|
+
OVERRIDE_MEMBERS.any? { |_kind, members| record[members.fetch(context)] }
|
|
16
|
+
new(record, context, path, key, lookups)
|
|
17
|
+
end
|
|
18
|
+
|
|
11
19
|
def descend(child_key)
|
|
12
|
-
self.class.new(record, context,
|
|
20
|
+
self.class.new(record, context, child_path(child_key), child_key, lookups)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def child_path(child_key)
|
|
24
|
+
return nil unless lookups
|
|
25
|
+
|
|
26
|
+
path ? "#{path}.#{child_key}" : child_key.to_s
|
|
13
27
|
end
|
|
14
28
|
|
|
15
29
|
def for_array_items
|
|
16
|
-
self.class.new(record, context, path, nil)
|
|
30
|
+
self.class.new(record, context, path, nil, lookups)
|
|
17
31
|
end
|
|
18
32
|
end
|
|
19
33
|
private_constant :BuildContext
|
|
@@ -27,11 +27,10 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def merge_non_object_item_variations(schemas)
|
|
30
|
-
|
|
31
|
-
typed = schemas.reject(&nullable_only)
|
|
30
|
+
typed = schemas.reject { |schema| nullable_only?(schema) }
|
|
32
31
|
|
|
33
32
|
merged = typed.size == 1 ? typed.first.dup : merge_multi(typed)
|
|
34
|
-
merged[:nullable] = true if schemas.any?(
|
|
33
|
+
merged[:nullable] = true if schemas.any? { |schema| nullable_only?(schema) } && merged.is_a?(Hash)
|
|
35
34
|
merged
|
|
36
35
|
end
|
|
37
36
|
|
|
@@ -43,19 +42,32 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
43
42
|
def merge_property_variations(variations, allow_recursive_merge:)
|
|
44
43
|
property_keys(variations).each_with_object({}) do |key, merged_props|
|
|
45
44
|
all = variations.map { |v| v[:properties]&.[](key) }
|
|
46
|
-
|
|
45
|
+
read_only = lift_read_only!(all)
|
|
46
|
+
prop_variations = all.reject { |p| p.nil? || nullable_only?(p) }
|
|
47
47
|
has_nullable = nullable_present?(all, recursive: allow_recursive_merge)
|
|
48
48
|
|
|
49
49
|
next if prop_variations.empty? && !has_nullable
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
merged = merge_single_property(prop_variations, has_nullable,
|
|
52
|
+
variations_total: variations.size,
|
|
53
|
+
allow_recursive_merge: allow_recursive_merge,)
|
|
54
|
+
merged[:readOnly] = read_only unless read_only.nil?
|
|
55
|
+
merged_props[key] = merged
|
|
54
56
|
end
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
private
|
|
58
60
|
|
|
61
|
+
# The variations of one property come from one side of one exchange, so
|
|
62
|
+
# they agree on readOnly. The merge rebuilds the schema, so the caller puts
|
|
63
|
+
# it back on the result, beside a oneOf like nullable. The schemas are the
|
|
64
|
+
# builder's own, so the flag comes off them in place.
|
|
65
|
+
def lift_read_only!(all)
|
|
66
|
+
read_only = all.find { |p| p.is_a?(Hash) && p.key?(:readOnly) }&.[](:readOnly)
|
|
67
|
+
all.each { |p| p&.delete(:readOnly) } unless read_only.nil?
|
|
68
|
+
read_only
|
|
69
|
+
end
|
|
70
|
+
|
|
59
71
|
def merge_object_variations(variations)
|
|
60
72
|
merged = { type: 'object' }
|
|
61
73
|
if variations.any? { |v| v.key?(:properties) }
|
|
@@ -74,7 +86,10 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
74
86
|
end
|
|
75
87
|
|
|
76
88
|
def property_keys(variations)
|
|
77
|
-
|
|
89
|
+
keys = []
|
|
90
|
+
variations.each { |v| v[:properties]&.each_key { |key| keys << key } }
|
|
91
|
+
keys.uniq!
|
|
92
|
+
keys
|
|
78
93
|
end
|
|
79
94
|
|
|
80
95
|
# `recursive` mirrors merge_variations' original rule that also treats
|
|
@@ -82,10 +97,14 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
82
97
|
# looks at outright nil or `{ nullable: true }` markers.
|
|
83
98
|
def nullable_present?(all_props, recursive:)
|
|
84
99
|
all_props.any? do |p|
|
|
85
|
-
p.nil? || (p.is_a?(Hash) && (p
|
|
100
|
+
p.nil? || (p.is_a?(Hash) && (nullable_only?(p) || (recursive && p[:nullable] == true)))
|
|
86
101
|
end
|
|
87
102
|
end
|
|
88
103
|
|
|
104
|
+
def nullable_only?(schema)
|
|
105
|
+
schema.size == 1 && schema.key?(:nullable)
|
|
106
|
+
end
|
|
107
|
+
|
|
89
108
|
def merge_single_property(prop_variations, has_nullable, variations_total:, allow_recursive_merge:)
|
|
90
109
|
return { nullable: true } if prop_variations.empty?
|
|
91
110
|
|
|
@@ -7,6 +7,7 @@ require_relative 'schema_builder/variation_merger'
|
|
|
7
7
|
class << RSpec::OpenAPI::SchemaBuilder
|
|
8
8
|
# Record members holding the per-context lookup maps, by context (and kind).
|
|
9
9
|
ENUM_MEMBERS = { request: :request_enum, response: :response_enum }.freeze
|
|
10
|
+
READ_ONLY_MEMBERS = { request: :request_read_only, response: :response_read_only }.freeze
|
|
10
11
|
OVERRIDE_MEMBERS = {
|
|
11
12
|
additional_properties: {
|
|
12
13
|
request: :request_additional_properties, response: :response_additional_properties,
|
|
@@ -16,13 +17,20 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
16
17
|
},
|
|
17
18
|
}.freeze
|
|
18
19
|
|
|
20
|
+
# GET and DELETE never have a request body in OpenAPI.
|
|
21
|
+
BODYLESS_METHODS = ['delete', 'get'].freeze
|
|
22
|
+
|
|
23
|
+
# The schema holds symbol keys like the document, and nothing of the record:
|
|
24
|
+
# the data it carries is copied, so the merger can take the schema over.
|
|
25
|
+
#
|
|
19
26
|
# @param [RSpec::OpenAPI::Record] record
|
|
20
27
|
# @return [Hash]
|
|
21
28
|
def build(record)
|
|
29
|
+
http_method = record.http_method.downcase
|
|
22
30
|
{
|
|
23
31
|
paths: {
|
|
24
|
-
normalize_path(record.path) => {
|
|
25
|
-
|
|
32
|
+
normalize_path(record.path).to_sym => {
|
|
33
|
+
http_method.to_sym => build_operation(record, http_method),
|
|
26
34
|
},
|
|
27
35
|
},
|
|
28
36
|
}
|
|
@@ -30,20 +38,19 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
30
38
|
|
|
31
39
|
private
|
|
32
40
|
|
|
33
|
-
def build_operation(record)
|
|
34
|
-
|
|
35
|
-
# GET and DELETE never have a request body in OpenAPI.
|
|
36
|
-
request_body = ['delete', 'get'].include?(http_method) ? nil : build_request_body(record)
|
|
37
|
-
{
|
|
41
|
+
def build_operation(record, http_method)
|
|
42
|
+
operation = {
|
|
38
43
|
summary: record.summary,
|
|
39
|
-
tags: record.tags,
|
|
44
|
+
tags: copy(record.tags),
|
|
40
45
|
operationId: record.operation_id,
|
|
41
|
-
security: record.security,
|
|
46
|
+
security: copy(record.security),
|
|
42
47
|
deprecated: record.deprecated ? true : nil,
|
|
43
48
|
parameters: build_parameters(record),
|
|
44
|
-
requestBody:
|
|
45
|
-
responses: { record.status.to_s => build_response(record) },
|
|
46
|
-
}
|
|
49
|
+
requestBody: BODYLESS_METHODS.include?(http_method) ? nil : build_request_body(record),
|
|
50
|
+
responses: { record.status.to_s.to_sym => build_response(record) },
|
|
51
|
+
}
|
|
52
|
+
operation.compact!
|
|
53
|
+
operation
|
|
47
54
|
end
|
|
48
55
|
|
|
49
56
|
def build_response(record)
|
|
@@ -67,18 +74,18 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
67
74
|
def build_content(record)
|
|
68
75
|
disposition = normalize_content_disposition(record.response_content_disposition)
|
|
69
76
|
content_type = normalize_content_type(record.response_content_type)
|
|
70
|
-
ctx = BuildContext.
|
|
77
|
+
ctx = BuildContext.root(record, :response)
|
|
71
78
|
|
|
72
79
|
if RSpec::OpenAPI.supports_item_schema? && RSpec::OpenAPI.sequential_media_type?(content_type)
|
|
73
80
|
item_schema = build_stream_item_schema(record.response_body, content_type, ctx)
|
|
74
|
-
return { content_type => { itemSchema: item_schema } } if item_schema
|
|
81
|
+
return { content_type.to_sym => { itemSchema: item_schema } } if item_schema
|
|
75
82
|
end
|
|
76
83
|
|
|
77
84
|
schema = build_property(record.response_body, ctx, disposition: disposition)
|
|
78
85
|
example = response_example(record, disposition: disposition)
|
|
79
86
|
|
|
80
87
|
body = build_example_body(schema, record, mode: record.response_example_mode, example: example)
|
|
81
|
-
{ content_type => body }
|
|
88
|
+
{ content_type.to_sym => body }
|
|
82
89
|
end
|
|
83
90
|
|
|
84
91
|
def build_stream_item_schema(raw, content_type, ctx)
|
|
@@ -96,18 +103,30 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
96
103
|
|
|
97
104
|
case mode
|
|
98
105
|
when :multiple
|
|
99
|
-
{ schema: schema, examples: { record
|
|
106
|
+
{ schema: schema, examples: { example_name(record) => build_named_example(record, example) } }
|
|
100
107
|
else
|
|
101
108
|
# :single (default)
|
|
102
109
|
# :single may emit nil example or nil _example_summary; compact strips them.
|
|
103
|
-
|
|
110
|
+
body = {
|
|
111
|
+
schema: schema,
|
|
112
|
+
example: example,
|
|
113
|
+
_example_key: record.example_key,
|
|
114
|
+
_example_summary: example_summary(record),
|
|
115
|
+
}
|
|
116
|
+
body.compact!
|
|
117
|
+
body
|
|
104
118
|
end
|
|
105
119
|
end
|
|
106
120
|
|
|
107
121
|
def response_example(record, disposition:)
|
|
108
|
-
return nil if !example_enabled?(record) || disposition
|
|
122
|
+
return nil if !example_enabled?(record) || disposition || record.response_example_mode == :none
|
|
123
|
+
|
|
124
|
+
build_example(record.response_body)
|
|
125
|
+
end
|
|
109
126
|
|
|
110
|
-
|
|
127
|
+
# Named like the loader names the examples it reads back.
|
|
128
|
+
def example_name(record)
|
|
129
|
+
RSpec::OpenAPI::ExampleKey.normalize(record.example_key).to_sym
|
|
111
130
|
end
|
|
112
131
|
|
|
113
132
|
def build_named_example(record, value)
|
|
@@ -115,10 +134,6 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
115
134
|
summary ? { summary: summary, value: value } : { value: value }
|
|
116
135
|
end
|
|
117
136
|
|
|
118
|
-
def example_metadata(record)
|
|
119
|
-
{ _example_key: record.example_key, _example_summary: example_summary(record) }
|
|
120
|
-
end
|
|
121
|
-
|
|
122
137
|
def example_summary(record)
|
|
123
138
|
return nil unless RSpec::OpenAPI.enable_example_summary
|
|
124
139
|
return nil if record.example_name.nil? || record.example_name.empty?
|
|
@@ -134,15 +149,12 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
134
149
|
parameters = record.path_params.map do |key, value|
|
|
135
150
|
build_parameter(key, value, location: 'path', record: record)
|
|
136
151
|
end
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
build_parameter(key, value, location: 'query', record: record)
|
|
152
|
+
flatten_query_params(record.query_params).each do |key, value|
|
|
153
|
+
parameters << build_parameter(key, value, location: 'query', record: record)
|
|
140
154
|
end
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
build_parameter(key, value, location: 'header', record: record)
|
|
155
|
+
record.request_headers.each do |key, value|
|
|
156
|
+
parameters << build_parameter(key, value, location: 'header', record: record)
|
|
144
157
|
end
|
|
145
|
-
|
|
146
158
|
parameters.empty? ? nil : parameters
|
|
147
159
|
end
|
|
148
160
|
|
|
@@ -152,21 +164,26 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
152
164
|
is_query = location == 'query'
|
|
153
165
|
required = is_query ? record.required_request_params.include?(key) : true
|
|
154
166
|
cast = try_cast(value)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
name = key.to_s
|
|
168
|
+
ctx = BuildContext.root(record, :request, name, key)
|
|
169
|
+
parameter = {
|
|
170
|
+
name: name,
|
|
158
171
|
in: location,
|
|
159
172
|
required: required,
|
|
160
173
|
schema: build_property(cast, ctx),
|
|
161
174
|
example: (cast if example_enabled?(record)),
|
|
162
|
-
}
|
|
175
|
+
}
|
|
176
|
+
parameter.compact!
|
|
177
|
+
parameter
|
|
163
178
|
end
|
|
164
179
|
|
|
165
180
|
def build_response_headers(record)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
181
|
+
headers = {}
|
|
182
|
+
record.response_headers.each do |key, value|
|
|
183
|
+
ctx = BuildContext.root(record, :response, key.to_s, key)
|
|
184
|
+
headers[key.to_sym] = { schema: build_property(try_cast(value), ctx) }
|
|
169
185
|
end
|
|
186
|
+
headers
|
|
170
187
|
end
|
|
171
188
|
|
|
172
189
|
def flatten_query_params(params, parent_key = nil)
|
|
@@ -186,12 +203,12 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
186
203
|
return nil if record.status >= 400 && record.request_example_mode != :multiple
|
|
187
204
|
|
|
188
205
|
content_type = normalize_content_type(record.request_content_type)
|
|
189
|
-
ctx = BuildContext.
|
|
206
|
+
ctx = BuildContext.root(record, :request)
|
|
190
207
|
schema = build_property(record.request_params, ctx)
|
|
191
|
-
example = example_enabled?(record)
|
|
208
|
+
example = build_example(record.request_params) if example_enabled?(record) && record.request_example_mode != :none
|
|
192
209
|
|
|
193
210
|
body = build_example_body(schema, record, mode: record.request_example_mode, example: example)
|
|
194
|
-
{ content: { content_type => body } }
|
|
211
|
+
{ content: { content_type.to_sym => body } }
|
|
195
212
|
end
|
|
196
213
|
|
|
197
214
|
def build_property(value, ctx, disposition: nil)
|
|
@@ -214,14 +231,25 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
214
231
|
if override.is_a?(Hash) && !override.empty?
|
|
215
232
|
# Schema override: the object's keys are dynamic — replace captured
|
|
216
233
|
# `properties` / `required` with the supplied dictionary value schema.
|
|
217
|
-
property[:additionalProperties] = override
|
|
234
|
+
property[:additionalProperties] = copy(override)
|
|
218
235
|
return
|
|
219
236
|
end
|
|
220
237
|
|
|
238
|
+
# Only an object property can be readOnly. A request that sends a candidate
|
|
239
|
+
# says false, which wins where the two sides meet in one schema (see
|
|
240
|
+
# SchemaMerger).
|
|
241
|
+
read_only = ctx.record[READ_ONLY_MEMBERS.fetch(ctx.context)]
|
|
221
242
|
properties = {}
|
|
222
|
-
|
|
243
|
+
required = []
|
|
244
|
+
value.each do |k, v|
|
|
245
|
+
child = build_property(v, ctx.descend(k))
|
|
246
|
+
name = k.to_s
|
|
247
|
+
child[:readOnly] = ctx.context == :response if read_only&.[](name)
|
|
248
|
+
properties[name.to_sym] = child
|
|
249
|
+
required << name
|
|
250
|
+
end
|
|
223
251
|
property[:properties] = properties
|
|
224
|
-
property[:required] =
|
|
252
|
+
property[:required] = required
|
|
225
253
|
apply_additional_properties(property, override,
|
|
226
254
|
infer_override(ctx.path, ctx.record, ctx.context, :hybrid_additional_properties),)
|
|
227
255
|
end
|
|
@@ -235,7 +263,7 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
235
263
|
if [true, false].include?(override)
|
|
236
264
|
property[:additionalProperties] = override
|
|
237
265
|
elsif hybrid_override.is_a?(Hash) && !hybrid_override.empty?
|
|
238
|
-
property[:additionalProperties] = hybrid_override
|
|
266
|
+
property[:additionalProperties] = copy(hybrid_override)
|
|
239
267
|
end
|
|
240
268
|
end
|
|
241
269
|
|
|
@@ -256,7 +284,7 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
256
284
|
end
|
|
257
285
|
end
|
|
258
286
|
|
|
259
|
-
result[:enum] = enum if enum
|
|
287
|
+
result[:enum] = copy(enum) if enum
|
|
260
288
|
result
|
|
261
289
|
end
|
|
262
290
|
|
|
@@ -296,31 +324,36 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
296
324
|
Integer(value, exception: false) || value
|
|
297
325
|
end
|
|
298
326
|
|
|
327
|
+
# A copy of a recorded value with symbol keys, and the filename of an upload.
|
|
299
328
|
def build_example(value)
|
|
300
|
-
return nil if value.nil?
|
|
301
|
-
|
|
302
|
-
adjust_value(value.dup)
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
def adjust_params(hash)
|
|
306
|
-
hash.transform_values! { |v| adjust_value(v) }
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
def adjust_value(value)
|
|
310
329
|
case value
|
|
330
|
+
when Hash
|
|
331
|
+
example = {}
|
|
332
|
+
value.each { |key, item| example[key.to_sym] = build_example(item) }
|
|
333
|
+
example
|
|
334
|
+
when Array then value.map { |item| build_example(item) }
|
|
311
335
|
when ActionDispatch::Http::UploadedFile then value.original_filename
|
|
312
|
-
when Hash then adjust_params(value)
|
|
313
|
-
when Array then value.map { |item| adjust_value(item) }
|
|
314
336
|
else value
|
|
315
337
|
end
|
|
316
338
|
end
|
|
317
339
|
|
|
340
|
+
# A record may hold what the metadata of a whole example group holds.
|
|
341
|
+
def copy(value)
|
|
342
|
+
RSpec::OpenAPI::KeyTransformer.symbolize(value)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# gsub copies the path even when nothing matches.
|
|
318
346
|
def normalize_path(path)
|
|
347
|
+
return path unless path.include?(':')
|
|
348
|
+
|
|
319
349
|
path.gsub(%r{/:([^:/]+)}, '/{\1}')
|
|
320
350
|
end
|
|
321
351
|
|
|
352
|
+
# sub copies the content type even when it carries no parameters.
|
|
322
353
|
def normalize_content_type(content_type)
|
|
323
|
-
content_type&.
|
|
354
|
+
return content_type unless content_type&.include?(';')
|
|
355
|
+
|
|
356
|
+
content_type.sub(/;.+\z/, '')
|
|
324
357
|
end
|
|
325
358
|
|
|
326
359
|
# Same logic as normalize_content_type – strips header parameters after ';'
|
|
@@ -16,6 +16,12 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
16
16
|
cleanup_one_of_properties!(base, spec, 'components.schemas.*')
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# The request and response bodies, where most of the cleanup happens.
|
|
20
|
+
MEDIA_TYPE_SELECTORS = [
|
|
21
|
+
'paths.*.*.requestBody.content.application/json',
|
|
22
|
+
'paths.*.*.responses.*.content.application/json',
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
19
25
|
# Cleanup specific elements that exists in the base but not in the spec
|
|
20
26
|
#
|
|
21
27
|
# @param [Hash] base
|
|
@@ -30,22 +36,16 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
30
36
|
# cleanup parameters
|
|
31
37
|
cleanup_array!(base, spec, 'paths.*.*.parameters', [:name, :in])
|
|
32
38
|
|
|
33
|
-
# cleanup
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.schema.properties.*')
|
|
40
|
-
cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.example.*')
|
|
41
|
-
cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.examples.*')
|
|
42
|
-
|
|
43
|
-
# cleanup properties of each variant once a body became a oneOf
|
|
44
|
-
cleanup_one_of_properties!(base, spec, 'paths.*.*.requestBody.content.application/json.schema')
|
|
45
|
-
cleanup_one_of_properties!(base, spec, 'paths.*.*.responses.*.content.application/json.schema')
|
|
39
|
+
# cleanup request and response bodies, each walked down to once
|
|
40
|
+
MEDIA_TYPE_SELECTORS.each do |selector|
|
|
41
|
+
RSpec::OpenAPI::HashHelper.each_matched(base, spec, selector) do |_parent, _key, media_type, fresh|
|
|
42
|
+
cleanup_media_type!(media_type, fresh)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
46
45
|
|
|
47
|
-
#
|
|
48
|
-
|
|
46
|
+
# A full run decides readOnly anew, so a property no request sends any
|
|
47
|
+
# more stops being held at false.
|
|
48
|
+
RSpec::OpenAPI::HashHelper.each_schema(base.dig(:components, :schemas)) { |schema| delete_false_read_only!(schema) }
|
|
49
49
|
|
|
50
50
|
base
|
|
51
51
|
end
|
|
@@ -62,19 +62,58 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
# One walk over the schemas once the components are updated, which also drops
|
|
66
|
+
# the fields the builder left for the merge. `readOnly: false` is how a
|
|
67
|
+
# request says it sends a candidate property. A component shared by a request
|
|
68
|
+
# and a response keeps it, or a later partial run that sees only the response
|
|
69
|
+
# would flip it back to true. Inline under paths each side has its own
|
|
70
|
+
# schema, so there it says nothing.
|
|
71
|
+
def cleanup_schemas!(base)
|
|
72
|
+
RSpec::OpenAPI::HashHelper.each_schema(base.dig(:components, :schemas)) do |schema|
|
|
73
|
+
delete_temporary_fields!(schema)
|
|
74
|
+
delete_empty_required!(schema)
|
|
75
|
+
end
|
|
76
|
+
RSpec::OpenAPI::HashHelper.each_schema(base[:paths]) do |schema|
|
|
77
|
+
delete_temporary_fields!(schema)
|
|
78
|
+
delete_empty_required!(schema)
|
|
79
|
+
delete_false_read_only!(schema)
|
|
73
80
|
end
|
|
74
81
|
end
|
|
75
82
|
|
|
76
83
|
private
|
|
77
84
|
|
|
85
|
+
# The description of a response whose example is not recorded is provisional,
|
|
86
|
+
# so it stays only where no documented test set one.
|
|
87
|
+
def delete_temporary_fields!(schema)
|
|
88
|
+
schema.delete(:_example_key)
|
|
89
|
+
schema.delete(:_example_summary)
|
|
90
|
+
schema.delete(:_example_name)
|
|
91
|
+
return unless (fallback = schema.delete(:_fallback_description))
|
|
92
|
+
|
|
93
|
+
schema[:description] ||= fallback
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A recorded example is data, so a field in it named `required` stays however
|
|
97
|
+
# it is shaped. An empty `required` must not be present.
|
|
98
|
+
def delete_empty_required!(schema)
|
|
99
|
+
schema.delete(:required) if schema.key?(:properties) && schema[:required] && schema[:required].empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def delete_false_read_only!(schema)
|
|
103
|
+
schema.delete(:readOnly) if schema[:readOnly] == false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The selectors are relative to the media type. A media type the spec does
|
|
107
|
+
# not hold has no fresh counterpart, and all of its entries are stale.
|
|
108
|
+
def cleanup_media_type!(media_type, fresh)
|
|
109
|
+
cleanup_hash!(media_type, fresh, 'schema.properties.*')
|
|
110
|
+
cleanup_hash!(media_type, fresh, 'example.*')
|
|
111
|
+
cleanup_hash!(media_type, fresh, 'examples.*')
|
|
112
|
+
|
|
113
|
+
# cleanup properties of each variant once a body became a oneOf
|
|
114
|
+
cleanup_one_of_properties!(media_type, fresh, 'schema')
|
|
115
|
+
end
|
|
116
|
+
|
|
78
117
|
# Security sits on operations, the hashes under a path item: a non-standard
|
|
79
118
|
# method is still inline here and only moves under additionalOperations later.
|
|
80
119
|
def each_operation(base)
|
|
@@ -85,26 +124,6 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
85
124
|
end
|
|
86
125
|
end
|
|
87
126
|
|
|
88
|
-
# Recursively remove temporary fields like :_example_key and :_example_name from the schema.
|
|
89
|
-
# Every caller has already established that it holds a Hash.
|
|
90
|
-
def cleanup_temporary_fields!(hash)
|
|
91
|
-
hash.delete(:_example_key)
|
|
92
|
-
hash.delete(:_example_summary)
|
|
93
|
-
hash.delete(:_example_name)
|
|
94
|
-
if (fallback = hash.delete(:_fallback_description))
|
|
95
|
-
hash[:description] ||= fallback
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
hash.each_value do |value|
|
|
99
|
-
case value
|
|
100
|
-
when Hash
|
|
101
|
-
cleanup_temporary_fields!(value)
|
|
102
|
-
when Array
|
|
103
|
-
value.each { |item| cleanup_temporary_fields!(item) if item.is_a?(Hash) }
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
127
|
def remove_parameters_conflicting_with_security_scheme!(path_definition, security_scheme, security_scheme_name)
|
|
109
128
|
security = path_definition[:security]
|
|
110
129
|
return if !security || security.empty?
|
|
@@ -121,9 +140,7 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
121
140
|
def cleanup_array!(base, spec, selector, fields_for_identity)
|
|
122
141
|
identity = ->(obj) { fields_for_identity.map { |field| obj[field] } }
|
|
123
142
|
|
|
124
|
-
RSpec::OpenAPI::HashHelper.
|
|
125
|
-
target_array = base.dig(*paths)
|
|
126
|
-
spec_array = spec.dig(*paths)
|
|
143
|
+
RSpec::OpenAPI::HashHelper.each_matched(base, spec, selector) do |_parent, _key, target_array, spec_array|
|
|
127
144
|
next unless target_array.is_a?(Array) && spec_array.is_a?(Array)
|
|
128
145
|
|
|
129
146
|
spec_identities = Set.new(spec_array.map(&identity))
|
|
@@ -137,10 +154,11 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
137
154
|
end
|
|
138
155
|
|
|
139
156
|
def cleanup_one_of_properties!(base, spec, selector)
|
|
140
|
-
RSpec::OpenAPI::HashHelper.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
RSpec::OpenAPI::HashHelper.each_matched(base, spec, selector) do |_parent, _key, schema, fresh|
|
|
158
|
+
next unless schema.is_a?(Hash) && fresh.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
variants = schema[:oneOf]
|
|
161
|
+
next unless variants.is_a?(Array)
|
|
144
162
|
|
|
145
163
|
variants.each { |variant| cleanup_variant_properties!(variant, variants, fresh) }
|
|
146
164
|
end
|
|
@@ -161,14 +179,13 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
161
179
|
variant[:properties].delete_if { |name, _| counterpart.dig(:properties, name).nil? }
|
|
162
180
|
end
|
|
163
181
|
|
|
164
|
-
#
|
|
165
|
-
# has a parent to delete the entry from.
|
|
182
|
+
# The stale entries are deleted after the walk, which iterates their parents.
|
|
166
183
|
def cleanup_hash!(base, spec, selector)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
base.dig(*paths[0..-2]).delete(paths.last) if exist_in_base && not_in_spec
|
|
184
|
+
stale = nil
|
|
185
|
+
RSpec::OpenAPI::HashHelper.each_matched(base, spec, selector) do |parent, key, value, counterpart|
|
|
186
|
+
(stale ||= []) << [parent, key] if !value.nil? && counterpart.nil?
|
|
171
187
|
end
|
|
188
|
+
stale&.each { |parent, key| parent.delete(key) }
|
|
172
189
|
base
|
|
173
190
|
end
|
|
174
191
|
end
|