rspec-openapi 0.34.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.
@@ -5,13 +5,32 @@ require_relative 'schema_builder/build_context'
5
5
  require_relative 'schema_builder/variation_merger'
6
6
 
7
7
  class << RSpec::OpenAPI::SchemaBuilder
8
+ # Record members holding the per-context lookup maps, by context (and kind).
9
+ ENUM_MEMBERS = { request: :request_enum, response: :response_enum }.freeze
10
+ READ_ONLY_MEMBERS = { request: :request_read_only, response: :response_read_only }.freeze
11
+ OVERRIDE_MEMBERS = {
12
+ additional_properties: {
13
+ request: :request_additional_properties, response: :response_additional_properties,
14
+ },
15
+ hybrid_additional_properties: {
16
+ request: :request_hybrid_additional_properties, response: :response_hybrid_additional_properties,
17
+ },
18
+ }.freeze
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
+ #
8
26
  # @param [RSpec::OpenAPI::Record] record
9
27
  # @return [Hash]
10
28
  def build(record)
29
+ http_method = record.http_method.downcase
11
30
  {
12
31
  paths: {
13
- normalize_path(record.path) => {
14
- record.http_method.downcase => build_operation(record),
32
+ normalize_path(record.path).to_sym => {
33
+ http_method.to_sym => build_operation(record, http_method),
15
34
  },
16
35
  },
17
36
  }
@@ -19,20 +38,19 @@ class << RSpec::OpenAPI::SchemaBuilder
19
38
 
20
39
  private
21
40
 
22
- def build_operation(record)
23
- http_method = record.http_method.downcase
24
- # GET and DELETE never have a request body in OpenAPI.
25
- request_body = ['delete', 'get'].include?(http_method) ? nil : build_request_body(record)
26
- {
41
+ def build_operation(record, http_method)
42
+ operation = {
27
43
  summary: record.summary,
28
- tags: record.tags,
44
+ tags: copy(record.tags),
29
45
  operationId: record.operation_id,
30
- security: record.security,
46
+ security: copy(record.security),
31
47
  deprecated: record.deprecated ? true : nil,
32
48
  parameters: build_parameters(record),
33
- requestBody: request_body,
34
- responses: { record.status.to_s => build_response(record) },
35
- }.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
36
54
  end
37
55
 
38
56
  def build_response(record)
@@ -56,18 +74,18 @@ class << RSpec::OpenAPI::SchemaBuilder
56
74
  def build_content(record)
57
75
  disposition = normalize_content_disposition(record.response_content_disposition)
58
76
  content_type = normalize_content_type(record.response_content_type)
59
- ctx = BuildContext.new(record: record, context: :response)
77
+ ctx = BuildContext.root(record, :response)
60
78
 
61
79
  if RSpec::OpenAPI.supports_item_schema? && RSpec::OpenAPI.sequential_media_type?(content_type)
62
80
  item_schema = build_stream_item_schema(record.response_body, content_type, ctx)
63
- return { content_type => { itemSchema: item_schema } } if item_schema
81
+ return { content_type.to_sym => { itemSchema: item_schema } } if item_schema
64
82
  end
65
83
 
66
84
  schema = build_property(record.response_body, ctx, disposition: disposition)
67
85
  example = response_example(record, disposition: disposition)
68
86
 
69
87
  body = build_example_body(schema, record, mode: record.response_example_mode, example: example)
70
- { content_type => body }
88
+ { content_type.to_sym => body }
71
89
  end
72
90
 
73
91
  def build_stream_item_schema(raw, content_type, ctx)
@@ -85,18 +103,30 @@ class << RSpec::OpenAPI::SchemaBuilder
85
103
 
86
104
  case mode
87
105
  when :multiple
88
- { schema: schema, examples: { record.example_key => build_named_example(record, example) } }
106
+ { schema: schema, examples: { example_name(record) => build_named_example(record, example) } }
89
107
  else
90
108
  # :single (default)
91
109
  # :single may emit nil example or nil _example_summary; compact strips them.
92
- { 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
93
118
  end
94
119
  end
95
120
 
96
121
  def response_example(record, disposition:)
97
- 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
98
126
 
99
- 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
100
130
  end
101
131
 
102
132
  def build_named_example(record, value)
@@ -104,10 +134,6 @@ class << RSpec::OpenAPI::SchemaBuilder
104
134
  summary ? { summary: summary, value: value } : { value: value }
105
135
  end
106
136
 
107
- def example_metadata(record)
108
- { _example_key: record.example_key, _example_summary: example_summary(record) }
109
- end
110
-
111
137
  def example_summary(record)
112
138
  return nil unless RSpec::OpenAPI.enable_example_summary
113
139
  return nil if record.example_name.nil? || record.example_name.empty?
@@ -123,15 +149,12 @@ class << RSpec::OpenAPI::SchemaBuilder
123
149
  parameters = record.path_params.map do |key, value|
124
150
  build_parameter(key, value, location: 'path', record: record)
125
151
  end
126
-
127
- parameters += flatten_query_params(record.query_params).map do |key, value|
128
- 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)
129
154
  end
130
-
131
- parameters += record.request_headers.map do |key, value|
132
- 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)
133
157
  end
134
-
135
158
  parameters.empty? ? nil : parameters
136
159
  end
137
160
 
@@ -141,21 +164,26 @@ class << RSpec::OpenAPI::SchemaBuilder
141
164
  is_query = location == 'query'
142
165
  required = is_query ? record.required_request_params.include?(key) : true
143
166
  cast = try_cast(value)
144
- ctx = BuildContext.new(record: record, context: :request, key: key, path: key.to_s)
145
- {
146
- name: key.to_s,
167
+ name = key.to_s
168
+ ctx = BuildContext.root(record, :request, name, key)
169
+ parameter = {
170
+ name: name,
147
171
  in: location,
148
172
  required: required,
149
173
  schema: build_property(cast, ctx),
150
174
  example: (cast if example_enabled?(record)),
151
- }.compact
175
+ }
176
+ parameter.compact!
177
+ parameter
152
178
  end
153
179
 
154
180
  def build_response_headers(record)
155
- record.response_headers.to_h do |key, value|
156
- ctx = BuildContext.new(record: record, context: :response, key: key, path: key.to_s)
157
- [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) }
158
185
  end
186
+ headers
159
187
  end
160
188
 
161
189
  def flatten_query_params(params, parent_key = nil)
@@ -175,12 +203,12 @@ class << RSpec::OpenAPI::SchemaBuilder
175
203
  return nil if record.status >= 400 && record.request_example_mode != :multiple
176
204
 
177
205
  content_type = normalize_content_type(record.request_content_type)
178
- ctx = BuildContext.new(record: record, context: :request)
206
+ ctx = BuildContext.root(record, :request)
179
207
  schema = build_property(record.request_params, ctx)
180
- 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
181
209
 
182
210
  body = build_example_body(schema, record, mode: record.request_example_mode, example: example)
183
- { content: { content_type => body } }
211
+ { content: { content_type.to_sym => body } }
184
212
  end
185
213
 
186
214
  def build_property(value, ctx, disposition: nil)
@@ -203,14 +231,25 @@ class << RSpec::OpenAPI::SchemaBuilder
203
231
  if override.is_a?(Hash) && !override.empty?
204
232
  # Schema override: the object's keys are dynamic — replace captured
205
233
  # `properties` / `required` with the supplied dictionary value schema.
206
- property[:additionalProperties] = override
234
+ property[:additionalProperties] = copy(override)
207
235
  return
208
236
  end
209
237
 
210
- property[:properties] = value.to_h do |k, v|
211
- [k, build_property(v, ctx.descend(k))]
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)]
242
+ properties = {}
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
212
250
  end
213
- property[:required] = property[:properties].keys
251
+ property[:properties] = properties
252
+ property[:required] = required
214
253
  apply_additional_properties(property, override,
215
254
  infer_override(ctx.path, ctx.record, ctx.context, :hybrid_additional_properties),)
216
255
  end
@@ -224,7 +263,7 @@ class << RSpec::OpenAPI::SchemaBuilder
224
263
  if [true, false].include?(override)
225
264
  property[:additionalProperties] = override
226
265
  elsif hybrid_override.is_a?(Hash) && !hybrid_override.empty?
227
- property[:additionalProperties] = hybrid_override
266
+ property[:additionalProperties] = copy(hybrid_override)
228
267
  end
229
268
  end
230
269
 
@@ -245,7 +284,7 @@ class << RSpec::OpenAPI::SchemaBuilder
245
284
  end
246
285
  end
247
286
 
248
- result[:enum] = enum if enum
287
+ result[:enum] = copy(enum) if enum
249
288
  result
250
289
  end
251
290
 
@@ -259,7 +298,7 @@ class << RSpec::OpenAPI::SchemaBuilder
259
298
  return nil if !path || !record
260
299
 
261
300
  # Keys are already normalized to strings by SharedExtractor.normalize_enum
262
- record.send("#{context}_enum")&.[](path.to_s)
301
+ record[ENUM_MEMBERS.fetch(context)]&.[](path.to_s)
263
302
  end
264
303
 
265
304
  # Looks up an override for the current path under one of the per-context
@@ -268,7 +307,7 @@ class << RSpec::OpenAPI::SchemaBuilder
268
307
  # distinguishable from "no override"; for :hybrid_additional_properties
269
308
  # plain lookup is enough because only Hash values are meaningful.
270
309
  def infer_override(path, record, context, kind)
271
- overrides = record.send("#{context}_#{kind}")
310
+ overrides = record[OVERRIDE_MEMBERS.fetch(kind).fetch(context)]
272
311
  return nil unless overrides
273
312
 
274
313
  # path is nil at the body root; nil.to_s == '' lets users target it via { '' => ... }.
@@ -282,36 +321,39 @@ class << RSpec::OpenAPI::SchemaBuilder
282
321
  return true if value == 'true'
283
322
  return false if value == 'false'
284
323
 
285
- Integer(value)
286
- rescue TypeError, ArgumentError
287
- value
324
+ Integer(value, exception: false) || value
288
325
  end
289
326
 
327
+ # A copy of a recorded value with symbol keys, and the filename of an upload.
290
328
  def build_example(value)
291
- return nil if value.nil?
292
-
293
- adjust_value(value.dup)
294
- end
295
-
296
- def adjust_params(hash)
297
- hash.transform_values! { |v| adjust_value(v) }
298
- end
299
-
300
- def adjust_value(value)
301
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) }
302
335
  when ActionDispatch::Http::UploadedFile then value.original_filename
303
- when Hash then adjust_params(value)
304
- when Array then value.map { |item| adjust_value(item) }
305
336
  else value
306
337
  end
307
338
  end
308
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.
309
346
  def normalize_path(path)
347
+ return path unless path.include?(':')
348
+
310
349
  path.gsub(%r{/:([^:/]+)}, '/{\1}')
311
350
  end
312
351
 
352
+ # sub copies the content type even when it carries no parameters.
313
353
  def normalize_content_type(content_type)
314
- content_type&.sub(/;.+\z/, '')
354
+ return content_type unless content_type&.include?(';')
355
+
356
+ content_type.sub(/;.+\z/, '')
315
357
  end
316
358
 
317
359
  # Same logic as normalize_content_type – strips header parameters after ';'
@@ -13,8 +13,15 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
13
13
  def cleanup_components_schemas!(base, spec)
14
14
  cleanup_hash!(base, spec, 'components.schemas.*')
15
15
  cleanup_hash!(base, spec, 'components.schemas.*.properties.*')
16
+ cleanup_one_of_properties!(base, spec, 'components.schemas.*')
16
17
  end
17
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
+
18
25
  # Cleanup specific elements that exists in the base but not in the spec
19
26
  #
20
27
  # @param [Hash] base
@@ -29,18 +36,16 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
29
36
  # cleanup parameters
30
37
  cleanup_array!(base, spec, 'paths.*.*.parameters', [:name, :in])
31
38
 
32
- # cleanup requestBody
33
- cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.schema.properties.*')
34
- cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.example.*')
35
- cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.examples.*')
36
-
37
- # cleanup responses
38
- cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.schema.properties.*')
39
- cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.example.*')
40
- cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.examples.*')
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
41
45
 
42
- # cleanup temporary fields used for internal processing
43
- 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) }
44
49
 
45
50
  base
46
51
  end
@@ -50,52 +55,72 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
50
55
 
51
56
  return if security_schemes.empty?
52
57
 
53
- paths_to_security_definitions = RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'paths', 'security')
54
-
55
- paths_to_security_definitions.each do |path|
56
- parent_path_definition = base.dig(*path.take(path.length - 1))
57
-
58
+ each_operation(base) do |operation|
58
59
  security_schemes.each do |security_scheme_name, security_scheme|
59
- remove_parameters_conflicting_with_security_scheme!(
60
- parent_path_definition, security_scheme, security_scheme_name,
61
- )
60
+ remove_parameters_conflicting_with_security_scheme!(operation, security_scheme, security_scheme_name)
62
61
  end
63
62
  end
64
63
  end
65
64
 
66
- def cleanup_empty_required_array!(base)
67
- paths_to_objects = [
68
- *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties'),
69
- *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'paths', 'properties'),
70
- ]
71
- paths_to_objects.each do |path|
72
- parent = base.dig(*path.take(path.length - 1))
73
- next unless parent
74
-
75
- # "required" array must not be present if empty
76
- parent.delete(:required) if parent[:required] && parent[:required].empty?
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)
77
80
  end
78
81
  end
79
82
 
80
83
  private
81
84
 
82
- # Recursively remove temporary fields like :_example_key and :_example_name from the schema.
83
- # Every caller has already established that it holds a Hash.
84
- def cleanup_temporary_fields!(hash)
85
- hash.delete(:_example_key)
86
- hash.delete(:_example_summary)
87
- hash.delete(:_example_name)
88
- if (fallback = hash.delete(:_fallback_description))
89
- hash[:description] ||= fallback
90
- end
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))
91
92
 
92
- hash.each_value do |value|
93
- case value
94
- when Hash
95
- cleanup_temporary_fields!(value)
96
- when Array
97
- value.each { |item| cleanup_temporary_fields!(item) if item.is_a?(Hash) }
98
- end
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
+
117
+ # Security sits on operations, the hashes under a path item: a non-standard
118
+ # method is still inline here and only moves under additionalOperations later.
119
+ def each_operation(base)
120
+ base[:paths].each_value do |path_item|
121
+ next unless path_item.is_a?(Hash)
122
+
123
+ path_item.each_value { |operation| yield operation if operation.is_a?(Hash) }
99
124
  end
100
125
  end
101
126
 
@@ -113,33 +138,54 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
113
138
  end
114
139
 
115
140
  def cleanup_array!(base, spec, selector, fields_for_identity)
116
- marshal = lambda do |obj|
117
- Marshal.dump(obj.slice(*fields_for_identity))
118
- end
141
+ identity = ->(obj) { fields_for_identity.map { |field| obj[field] } }
119
142
 
120
- RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
121
- target_array = base.dig(*paths)
122
- spec_array = spec.dig(*paths)
143
+ RSpec::OpenAPI::HashHelper.each_matched(base, spec, selector) do |_parent, _key, target_array, spec_array|
123
144
  next unless target_array.is_a?(Array) && spec_array.is_a?(Array)
124
145
 
125
- spec_identities = Set.new(spec_array.map(&marshal))
126
- target_array.select! { |e| spec_identities.include?(marshal.call(e)) }
127
- target_array.sort_by! { |param| fields_for_identity.map { |f| param[f] }.join('-') }
146
+ spec_identities = Set.new(spec_array.map(&identity))
147
+ target_array.select! { |e| spec_identities.include?(identity.call(e)) }
148
+ target_array.sort_by! { |param| identity.call(param).join('-') }
128
149
  # Keep the last duplicate to produce the result stably
129
- deduplicated = target_array.reverse.uniq { |param| param.slice(*fields_for_identity) }.reverse
150
+ deduplicated = target_array.reverse.uniq(&identity).reverse
130
151
  target_array.replace(deduplicated)
131
152
  end
132
153
  base
133
154
  end
134
155
 
135
- # Every selector above names at least two segments, so a matched path always
136
- # has a parent to delete the entry from.
156
+ def cleanup_one_of_properties!(base, spec, selector)
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)
162
+
163
+ variants.each { |variant| cleanup_variant_properties!(variant, variants, fresh) }
164
+ end
165
+ base
166
+ end
167
+
168
+ # Variants are matched by type, since the spec may hold one of them as a bare
169
+ # schema or in another order. Two variants of one type can not be told apart
170
+ # that way, so those stay as they are, like a variant of a type the spec does
171
+ # not hold.
172
+ def cleanup_variant_properties!(variant, variants, fresh)
173
+ return unless variant.is_a?(Hash) && variant[:properties].is_a?(Hash)
174
+ return unless variants.one? { |other| other.is_a?(Hash) && other[:type] == variant[:type] }
175
+
176
+ counterpart = RSpec::OpenAPI::HashHelper.variant_of_type(fresh, variant[:type])
177
+ return unless counterpart
178
+
179
+ variant[:properties].delete_if { |name, _| counterpart.dig(:properties, name).nil? }
180
+ end
181
+
182
+ # The stale entries are deleted after the walk, which iterates their parents.
137
183
  def cleanup_hash!(base, spec, selector)
138
- RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
139
- exist_in_base = !base.dig(*paths).nil?
140
- not_in_spec = spec.dig(*paths).nil?
141
- 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?
142
187
  end
188
+ stale&.each { |parent, key| parent.delete(key) }
143
189
  base
144
190
  end
145
191
  end
@@ -3,11 +3,11 @@
3
3
  require 'fileutils'
4
4
  require 'yaml'
5
5
  require 'json'
6
+ require 'stringio'
6
7
 
7
8
  # For Ruby 2.7
8
9
  require 'date'
9
10
 
10
- # TODO: Support JSON
11
11
  class RSpec::OpenAPI::SchemaFile
12
12
  # @param [String] path
13
13
  def initialize(path)
@@ -16,28 +16,24 @@ class RSpec::OpenAPI::SchemaFile
16
16
 
17
17
  # Reads the existing spec, lets the block mutate it, writes the result back
18
18
  # and returns the (symbolized) spec so it can be mirrored to other files.
19
+ # A document that does not load is left as it is.
19
20
  # @return [Hash]
20
- def edit(&block)
21
+ def edit
21
22
  spec = read
22
- block.call(spec)
23
+ begin
24
+ yield spec
25
+ ensure
26
+ write(spec)
27
+ end
23
28
  spec
24
- ensure
25
- write(spec)
26
29
  end
27
30
 
28
31
  # Writes an already-built spec to this file, choosing the format from the
29
- # file extension.
32
+ # file extension. Both formats write the symbol keys as strings themselves.
30
33
  # @param [Hash] spec
31
34
  def write(spec)
32
- stringified = RSpec::OpenAPI::KeyTransformer.stringify(spec)
33
35
  FileUtils.mkdir_p(File.dirname(@path))
34
- output =
35
- if json?
36
- JSON.pretty_generate(stringified)
37
- else
38
- prepend_comment(YAML.dump(stringified))
39
- end
40
- File.write(@path, output)
36
+ File.write(@path, json? ? JSON.pretty_generate(spec) : yaml_document(spec))
41
37
  end
42
38
 
43
39
  private
@@ -46,18 +42,29 @@ class RSpec::OpenAPI::SchemaFile
46
42
  def read
47
43
  return {} unless File.exist?(@path)
48
44
 
49
- content = YAML.safe_load(File.read(@path), permitted_classes: [Date, Time]) # this can also parse JSON
50
- return {} if content.nil?
45
+ content = File.read(@path)
46
+ spec = json? ? read_json(content) : RSpec::OpenAPI::YAMLLoader.load(content)
47
+ spec.nil? ? {} : spec
48
+ end
49
+
50
+ def read_json(content)
51
+ return nil if content.match?(/\A\s*\z/)
51
52
 
52
- RSpec::OpenAPI::KeyTransformer.symbolize(content)
53
+ spec = JSON.parse(content, symbolize_names: true, max_nesting: false)
54
+ RSpec::OpenAPI::KeyTransformer.normalize_example_names!(spec)
53
55
  end
54
56
 
55
- def prepend_comment(content)
56
- return content if RSpec::OpenAPI.comment.nil?
57
+ def yaml_document(spec)
58
+ io = StringIO.new(+'')
59
+ io.write(comment_lines) unless RSpec::OpenAPI.comment.nil?
60
+ RSpec::OpenAPI::YAMLDumper.dump(spec, io)
61
+ io.string
62
+ end
57
63
 
64
+ def comment_lines
58
65
  comment = RSpec::OpenAPI.comment.dup
59
66
  comment << "\n" unless comment.end_with?("\n")
60
- "#{comment.gsub(/^/, '# ').gsub(/^# \n/, "#\n")}#{content}"
67
+ comment.gsub(/^/, '# ').gsub(/^# \n/, "#\n")
61
68
  end
62
69
 
63
70
  def json?