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.
@@ -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
- RSpec::OpenAPI.path_records[path] << record if record
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
- result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records)
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
- RSpec.configuration.reporter.message colorizer.wrap(error_message, :failure)
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
- BuildContext = Struct.new(:record, :context, :path, :key) do
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, path ? "#{path}.#{child_key}" : child_key.to_s, child_key)
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
- nullable_only = ->(schema) { schema.keys == [:nullable] }
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?(&nullable_only) && merged.is_a?(Hash)
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
- prop_variations = all.reject { |p| p.nil? || p.keys == [:nullable] }
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
- merged_props[key] = merge_single_property(prop_variations, has_nullable,
52
- variations_total: variations.size,
53
- allow_recursive_merge: allow_recursive_merge,)
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
- variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
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.keys == [:nullable] || (recursive && p[:nullable] == true)))
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
- record.http_method.downcase => build_operation(record),
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
- http_method = record.http_method.downcase
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: request_body,
45
- responses: { record.status.to_s => build_response(record) },
46
- }.compact
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.new(record, :response)
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.example_key => build_named_example(record, example) } }
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
- { schema: schema, example: example, **example_metadata(record) }.compact
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
- record.response_body
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
- parameters += flatten_query_params(record.query_params).map do |key, value|
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
- parameters += record.request_headers.map do |key, value|
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
- ctx = BuildContext.new(record, :request, key.to_s, key)
156
- {
157
- name: key.to_s,
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
- }.compact
175
+ }
176
+ parameter.compact!
177
+ parameter
163
178
  end
164
179
 
165
180
  def build_response_headers(record)
166
- record.response_headers.to_h do |key, value|
167
- ctx = BuildContext.new(record, :response, key.to_s, key)
168
- [key, { schema: build_property(try_cast(value), ctx) }]
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.new(record, :request)
206
+ ctx = BuildContext.root(record, :request)
190
207
  schema = build_property(record.request_params, ctx)
191
- example = example_enabled?(record) ? build_example(record.request_params) : nil
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
- value.each { |k, v| properties[k] = build_property(v, ctx.descend(k)) }
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] = properties.keys
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&.sub(/;.+\z/, '')
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 requestBody
34
- cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.schema.properties.*')
35
- cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.example.*')
36
- cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.examples.*')
37
-
38
- # cleanup responses
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
- # cleanup temporary fields used for internal processing
48
- cleanup_temporary_fields!(base)
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
- # A recorded example is data, so a field in it named `required` stays however
66
- # it is shaped.
67
- def cleanup_empty_required_array!(base)
68
- [base.dig(:components, :schemas), base[:paths]].each do |root|
69
- RSpec::OpenAPI::HashHelper.each_schema(root) do |schema|
70
- # "required" array must not be present if empty
71
- schema.delete(:required) if schema.key?(:properties) && schema[:required] && schema[:required].empty?
72
- end
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.matched_paths(base, selector).each do |paths|
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.matched_paths(base, selector).each do |paths|
141
- variants = base.dig(*paths, :oneOf)
142
- fresh = spec.dig(*paths)
143
- next unless variants.is_a?(Array) && fresh.is_a?(Hash)
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
- # Every selector above names at least two segments, so a matched path always
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
- RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
168
- exist_in_base = !base.dig(*paths).nil?
169
- not_in_spec = spec.dig(*paths).nil?
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