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.
@@ -25,7 +25,25 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
25
25
  merge_schema!(base, RSpec::OpenAPI::KeyTransformer.symbolize(spec))
26
26
  end
27
27
 
28
- SIMILARITY_THRESHOLD = 0.5
28
+ # Same, for a spec built for this merge alone, like the schema of a record.
29
+ # Its keys are symbols already, and its hashes and arrays become part of base
30
+ # rather than being copied, so the caller must not use them afterwards.
31
+ #
32
+ # @param [Hash] base
33
+ # @param [Hash] spec
34
+ def merge_built!(base, spec)
35
+ merge_schema!(base, spec)
36
+ end
37
+
38
+ SCHEMA_KEYS = [:schema, :schemas, :itemSchema].freeze
39
+ DATA_KEYS = [:example, :examples, :default, :enum].freeze
40
+ NUMERIC_TYPES = ['integer', 'number'].freeze
41
+ ANNOTATION_KEYS = [
42
+ :title, :description, :deprecated, :readOnly, :writeOnly, :example, :examples, :default, :externalDocs, :xml,
43
+ :discriminator,
44
+ ].freeze
45
+ # Like nullable, readOnly describes the whole value, so it stays beside a oneOf.
46
+ ONE_OF_WRAPPER_KEYS = [:nullable, :readOnly].freeze
29
47
 
30
48
  private
31
49
 
@@ -34,19 +52,54 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
34
52
  # may be an array whose Hash elements have a key name.
35
53
  #
36
54
  # TODO: Should we probably force-merge `summary` regardless of manual modifications?
37
- def merge_schema!(base, spec)
38
- if (options = base[:oneOf])
39
- merge_closest_match!(options, spec)
40
-
41
- return base
42
- end
55
+ def merge_schema!(base, spec, in_schema: false)
56
+ return merge_one_of!(base, spec) if base[:oneOf]
43
57
 
44
58
  prune_stale_object_fields!(base, spec)
59
+ spec = widen_to_number!(base, spec) if in_schema
45
60
 
46
- spec.each { |key, value| merge_entry!(base, key, value) }
61
+ spec.each { |key, value| merge_schema_entry!(base, key, value, in_schema) }
47
62
  base
48
63
  end
49
64
 
65
+ # Every integer is a number, so a value seen as both is a number. The format
66
+ # of the integer side would be wrong on a number, so only the number's stays.
67
+ def widen_to_number!(base, spec)
68
+ return spec if base[:type] == spec[:type] || !same_type?(base[:type], spec[:type])
69
+ return spec.reject { |key, _| [:type, :format].include?(key) } if base[:type] == 'number'
70
+
71
+ base.delete(:format)
72
+ spec
73
+ end
74
+
75
+ def merge_schema_entry!(base, key, value, in_schema)
76
+ if in_schema && key == :properties && base[key].is_a?(Hash) && value.is_a?(Hash)
77
+ value.each { |name, property| merge_entry!(base[key], name, property, true) }
78
+ elsif in_schema && key == :readOnly
79
+ merge_read_only!(base, value)
80
+ elsif DATA_KEYS.include?(key)
81
+ merge_data!(base, key, value)
82
+ else
83
+ merge_entry!(base, key, value, in_schema || SCHEMA_KEYS.include?(key))
84
+ end
85
+ end
86
+
87
+ # A property only responses hold is readOnly, one a request sends is not. So
88
+ # false wins, and a side without an opinion leaves the other's.
89
+ def merge_read_only!(base, value)
90
+ base[:readOnly] = value && base.fetch(:readOnly, true)
91
+ end
92
+
93
+ # Recorded data stays data however it is shaped, so a field named like a
94
+ # schema key never gets merged as one.
95
+ def merge_data!(base, key, value)
96
+ if base[key].is_a?(Hash) && value.is_a?(Hash)
97
+ value.each { |name, item| merge_data!(base[key], name, item) }
98
+ else
99
+ base[key] = value
100
+ end
101
+ end
102
+
50
103
  # When the new spec converts an object to a dictionary (introduces
51
104
  # `additionalProperties` on a node that previously had `properties` /
52
105
  # `required`), drop the stale fields so the merged result reflects the
@@ -60,9 +113,9 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
60
113
  base.delete(:required)
61
114
  end
62
115
 
63
- def merge_entry!(base, key, value)
116
+ def merge_entry!(base, key, value, in_schema)
64
117
  if base[key].is_a?(Hash) && value.is_a?(Hash)
65
- merge_hash_entry!(base, key, value)
118
+ merge_hash_entry!(base, key, value, in_schema)
66
119
  elsif base[key].is_a?(Array) && value.is_a?(Array)
67
120
  # parameters need to be merged as if `name` and `in` were the Hash keys.
68
121
  merge_arrays(base, key, value)
@@ -71,18 +124,63 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
71
124
  end
72
125
  end
73
126
 
74
- def merge_hash_entry!(base, key, value)
127
+ def merge_hash_entry!(base, key, value, in_schema)
75
128
  # Handle example/examples conflict - convert to examples when mixed
76
129
  normalize_example_fields!(base[key], value)
77
130
 
78
- # If the new value has oneOf, replace the entire value instead of merging
79
- if value.key?(:oneOf)
131
+ if in_schema && one_of_needed?(base[key], value)
132
+ merge_one_of!(base[key], value)
133
+ elsif value.key?(:oneOf)
80
134
  base[key] = value
81
135
  elsif !base[key].key?(:$ref)
82
- merge_schema!(base[key], value)
136
+ merge_schema!(base[key], value, in_schema: in_schema)
83
137
  end
84
138
  end
85
139
 
140
+ def one_of_needed?(base, spec)
141
+ return false if base.key?(:$ref)
142
+
143
+ spec.key?(:oneOf) || conflicting_types?(base, spec)
144
+ end
145
+
146
+ def conflicting_types?(base, spec)
147
+ base[:type].is_a?(String) && spec[:type].is_a?(String) && !same_type?(base[:type], spec[:type])
148
+ end
149
+
150
+ def same_type?(first, second)
151
+ first == second || (NUMERIC_TYPES.include?(first) && NUMERIC_TYPES.include?(second))
152
+ end
153
+
154
+ def merge_one_of!(base, spec)
155
+ nullable = base.delete(:nullable) || spec[:nullable]
156
+ wrap_in_one_of!(base) unless base[:oneOf]
157
+
158
+ one_of_variants(spec).each { |variant| merge_variant!(base, variant) }
159
+ base[:nullable] = true if nullable
160
+ merge_read_only!(base, spec[:readOnly]) if spec.key?(:readOnly)
161
+ base
162
+ end
163
+
164
+ # Annotations describe the whole value, so they stay beside the oneOf. A schema
165
+ # without a type constrains every variant too, and a variant made of it would
166
+ # match every other type, so all of it stays beside the oneOf.
167
+ def wrap_in_one_of!(base)
168
+ # A hand-written `oneOf:` left empty reads back as nil.
169
+ base.delete(:oneOf)
170
+ variant = base[:type] ? base.reject { |key, _| annotation?(key) } : {}
171
+ rest = base.reject { |key, _| variant.key?(key) }
172
+ base.replace({ oneOf: [variant].reject(&:empty?) }.merge(rest))
173
+ end
174
+
175
+ def annotation?(key)
176
+ ANNOTATION_KEYS.include?(key) || key.start_with?('x-')
177
+ end
178
+
179
+ def one_of_variants(schema)
180
+ variants = schema[:oneOf] || [schema.reject { |key, _| ONE_OF_WRAPPER_KEYS.include?(key) }]
181
+ variants.reject { |variant| variant.is_a?(Hash) && variant.empty? }
182
+ end
183
+
86
184
  # do not ADD `properties` or `required` fields if `additionalProperties` field is present
87
185
  def skip_due_to_additional_properties?(base, key)
88
186
  base.key?(:additionalProperties) && [:properties, :required].include?(key)
@@ -132,16 +230,8 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
132
230
  end
133
231
 
134
232
  def merge_parameter_with_schema(base_param, new_param)
135
- base_schema = base_param[:schema]
136
- new_schema = new_param[:schema]
137
-
138
- # If schemas have different types, create a oneOf
139
- merged = if base_schema && new_schema && schemas_have_different_types?(base_schema, new_schema)
140
- merged_schema = merge_schemas_into_one_of(base_schema, new_schema)
141
- base_param.merge(new_param).merge(schema: merged_schema)
142
- else
143
- base_param.merge(new_param)
144
- end
233
+ merge_entry!(base_param, :schema, new_param[:schema], true)
234
+ merged = base_param.merge(new_param.reject { |key, _| key == :schema })
145
235
 
146
236
  # Once a parameter has been seen missing in any earlier test case, keep it optional
147
237
  # even if later test cases mark it required again.
@@ -150,37 +240,6 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
150
240
  merged
151
241
  end
152
242
 
153
- def schemas_have_different_types?(schema1, schema2)
154
- # If either already has oneOf, we need to merge into it
155
- return true if schema1[:oneOf] || schema2[:oneOf]
156
-
157
- type1 = schema1[:type]
158
- type2 = schema2[:type]
159
-
160
- type1 && type2 && type1 != type2
161
- end
162
-
163
- def merge_schemas_into_one_of(base_schema, new_schema)
164
- existing_types = extract_schema_types(base_schema)
165
- new_types = extract_schema_types(new_schema)
166
-
167
- all_types = existing_types + new_types
168
- all_types.uniq!
169
-
170
- # If only one type remains, return it directly
171
- return all_types.first if all_types.size == 1
172
-
173
- { oneOf: all_types }
174
- end
175
-
176
- def extract_schema_types(schema)
177
- if schema[:oneOf]
178
- schema[:oneOf].map { |s| s.reject { |k, _| k == :example } }
179
- else
180
- [schema.reject { |k, _| k == :example }]
181
- end
182
- end
183
-
184
243
  def index_parameters_by_identity(parameters)
185
244
  parameters.to_h { |p| [[p[:name], p[:in]], p] }
186
245
  end
@@ -209,20 +268,39 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
209
268
  hash[:examples] = { name => example }
210
269
  end
211
270
 
212
- def merge_closest_match!(options, spec)
213
- score, option = options.map { |option| [similarity(option, spec), option] }.max_by(&:first)
214
-
215
- return if option&.key?(:$ref)
216
-
217
- return if spec[:oneOf]
271
+ def merge_variant!(base, spec)
272
+ options = base[:oneOf]
273
+ return if spec[:oneOf] || options.include?(spec)
274
+ return if options.any? { |option| option.is_a?(Hash) && option.key?(:$ref) }
218
275
 
219
- if score.to_f > SIMILARITY_THRESHOLD
220
- merge_schema!(option, spec)
276
+ option = matching_option(base, spec)
277
+ if option
278
+ merge_schema!(option, spec, in_schema: true)
221
279
  else
222
280
  options.push(spec)
281
+ sort_by_type!(options)
223
282
  end
224
283
  end
225
284
 
285
+ # A value could match two variants of one type, so those merge into one, the
286
+ # closest when there are several. A discriminator tells the variants apart by
287
+ # a property, so only the variants that declare it are candidates there.
288
+ def matching_option(base, spec)
289
+ candidates = base[:oneOf].select { |option| option.is_a?(Hash) && same_type?(option[:type], spec[:type]) }
290
+ if (property = base.dig(:discriminator, :propertyName))
291
+ candidates.select! { |option| option.dig(:properties, property.to_sym) }
292
+ end
293
+ candidates.max_by { |option| similarity(option, spec) }
294
+ end
295
+
296
+ def sort_by_type!(options)
297
+ options.replace(options.sort_by.with_index { |option, index| [type_name(option), index] })
298
+ end
299
+
300
+ def type_name(schema)
301
+ schema.is_a?(Hash) ? schema[:type].to_s : ''
302
+ end
303
+
226
304
  def similarity(first, second)
227
305
  return 1 if first == second
228
306
 
@@ -231,7 +309,7 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
231
309
  when [Array, Array]
232
310
  (first & second).size / [first.size, second.size].max.to_f
233
311
  when [Hash, Hash]
234
- return 1 if first.merge(second).key?(:$ref)
312
+ return 1 if first.key?(:$ref) || second.key?(:$ref)
235
313
 
236
314
  intersection = first.keys & second.keys
237
315
  total_size = [first.size, second.size].max.to_f
@@ -23,7 +23,12 @@ class << RSpec::OpenAPI::SchemaSorter = Object.new
23
23
  deep_sort_by_selector!(spec, "#{operation}.responses")
24
24
 
25
25
  # content-type
26
+ deep_sort_by_selector!(spec, "#{operation}.requestBody.content")
26
27
  deep_sort_by_selector!(spec, "#{operation}.responses.*.content")
28
+
29
+ # named examples (keyed by test description)
30
+ deep_sort_by_selector!(spec, "#{operation}.requestBody.content.*.examples")
31
+ deep_sort_by_selector!(spec, "#{operation}.responses.*.content.*.examples")
27
32
  end
28
33
  end
29
34
 
@@ -32,13 +37,29 @@ class << RSpec::OpenAPI::SchemaSorter = Object.new
32
37
  # @param [Hash] base
33
38
  # @param [String] selector
34
39
  def deep_sort_by_selector!(base, selector)
35
- RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
36
- deep_sort_hash!(base.dig(*paths))
40
+ RSpec::OpenAPI::HashHelper.each_matched(base, nil, selector) do |_parent, _key, hash, _counterpart|
41
+ deep_sort_hash!(hash)
37
42
  end
38
43
  end
39
44
 
45
+ # Re-inserting a key moves it to the end, so re-inserting every key in sorted
46
+ # order sorts the hash in place. A document read back from a file is sorted
47
+ # already, so a hash whose keys are in order is left alone, without the two
48
+ # arrays of keys a sort would build. The keys are all symbols, which sort as
49
+ # their names do.
40
50
  def deep_sort_hash!(hash)
41
- sorted = hash.entries.sort_by { |k, _| k.to_s }.to_h.transform_keys(&:to_sym)
42
- hash.replace(sorted)
51
+ return if sorted?(hash)
52
+
53
+ hash.keys.sort.each { |key| hash[key] = hash.delete(key) }
54
+ end
55
+
56
+ def sorted?(hash)
57
+ previous = nil
58
+ hash.each_key do |key|
59
+ return false if previous && previous > key
60
+
61
+ previous = key
62
+ end
63
+ true
43
64
  end
44
65
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module OpenAPI
5
- VERSION = '0.34.0'
5
+ VERSION = '0.36.0'
6
6
  end
7
7
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ # Writes a document straight to the libyaml emitter. YAML.dump first builds a
6
+ # tree of Psych nodes and then walks it to make the same emitter calls, so the
7
+ # output is the same without the tree. Keys are written as strings, which is
8
+ # what they are read back as, so the document needs no stringified copy.
9
+ #
10
+ # Nothing in a document is shared, so there are no anchors to find and the
11
+ # bookkeeping for them is skipped.
12
+ class RSpec::OpenAPI::YAMLDumper < Psych::Visitors::YAMLTree
13
+ # @param [Object] object
14
+ # @param [IO, StringIO] io
15
+ def self.dump(object, io)
16
+ dumper = create({}, Psych::Emitter.new(io))
17
+ dumper << object
18
+ dumper.finish
19
+ io
20
+ end
21
+
22
+ # Stands in for the emitter while the superclass writes a string, to keep
23
+ # the arguments of the one scalar call it makes.
24
+ class Recorder
25
+ attr_reader :scalar_arguments
26
+
27
+ def initialize(emitter)
28
+ @emitter = emitter
29
+ end
30
+
31
+ def scalar(value, anchor, tag, plain, quoted, style) # rubocop:disable Metrics/ParameterLists
32
+ # The emitter copies a string of any encoding but UTF-8 on every call.
33
+ value = value.dup.force_encoding(Encoding::UTF_8) if value.encoding != Encoding::UTF_8 && value.ascii_only?
34
+ @scalar_arguments = [value, tag, plain, quoted, style].freeze
35
+ @emitter.scalar(value, anchor, tag, plain, quoted, style)
36
+ end
37
+ end
38
+ private_constant :Recorder
39
+
40
+ def initialize(emitter, scanner, options)
41
+ super
42
+ # How a string is written takes several regexp matches to decide, and a
43
+ # document repeats its keys and the names of its types thousands of times.
44
+ # So the decision is kept by key, which saves the string of the key too,
45
+ # and by string.
46
+ @key_scalars = {}.compare_by_identity
47
+ @string_scalars = {}
48
+ end
49
+
50
+ def visit_Hash(hash) # rubocop:disable Naming/MethodName
51
+ @emitter.start_mapping(nil, nil, true, Psych::Nodes::Mapping::BLOCK)
52
+ hash.each do |key, value|
53
+ emit_key(key)
54
+ accept(value)
55
+ end
56
+ @emitter.end_mapping
57
+ end
58
+
59
+ def visit_Array(array) # rubocop:disable Naming/MethodName
60
+ @emitter.start_sequence(nil, nil, true, Psych::Nodes::Sequence::BLOCK)
61
+ array.each { |item| accept(item) }
62
+ @emitter.end_sequence
63
+ end
64
+
65
+ def visit_String(string) # rubocop:disable Naming/MethodName
66
+ scalar = @string_scalars[string]
67
+ return emit_scalar(scalar) if scalar
68
+
69
+ @string_scalars[string] = recording { super }
70
+ end
71
+
72
+ private
73
+
74
+ def emit_key(key)
75
+ scalar = @key_scalars[key]
76
+ return emit_scalar(scalar) if scalar
77
+
78
+ name = key.to_s
79
+ visit_String(name)
80
+ @key_scalars[key] = @string_scalars[name]
81
+ end
82
+
83
+ def emit_scalar(scalar)
84
+ @emitter.scalar(scalar[0], nil, scalar[1], scalar[2], scalar[3], scalar[4])
85
+ end
86
+
87
+ def recording
88
+ emitter = @emitter
89
+ @emitter = recorder = Recorder.new(emitter)
90
+ yield
91
+ recorder.scalar_arguments
92
+ ensure
93
+ @emitter = emitter
94
+ end
95
+
96
+ # :nocov:
97
+ # Reached by values other than hashes, arrays and plain scalars, such as the
98
+ # dates of a hand-written document, which the suite does not hold.
99
+ def register(_target, yaml_obj)
100
+ yaml_obj
101
+ end
102
+ # :nocov:
103
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'date'
5
+
6
+ # Builds the document with symbol keys straight from the parser events.
7
+ # YAML.safe_load builds a tree of Psych nodes first and hands back string keys,
8
+ # which KeyTransformer.symbolize then converted in a copy of the whole document.
9
+ # The names of examples are normalized on the way, as that copy did.
10
+ #
11
+ # A key names an entry, so it is a string however it reads: `200:` is the name
12
+ # of a response, not a number. Resolving it as YAML.safe_load does would give
13
+ # a key with no name to take.
14
+ #
15
+ # Anchors, tags and merge keys are left to YAML.safe_load, which accepts or
16
+ # rejects them as it always did. A valid alias follows its anchor, so only a
17
+ # dangling one gets this far, and it is handed off as well.
18
+ class RSpec::OpenAPI::YAMLLoader < Psych::Handler
19
+ class Unsupported < StandardError; end
20
+ private_constant :Unsupported
21
+
22
+ PERMITTED_CLASSES = [Date, Time].freeze
23
+ # Marks a mapping waiting for its next key.
24
+ NONE = Object.new.freeze
25
+ private_constant :NONE
26
+
27
+ # @param [String] yaml
28
+ # @return [Object, nil] the first document, or nil when there is none
29
+ def self.load(yaml)
30
+ loader = new
31
+ Psych::Parser.new(loader).parse(yaml)
32
+ loader.root
33
+ rescue Unsupported
34
+ RSpec::OpenAPI::KeyTransformer.symbolize(YAML.safe_load(yaml, permitted_classes: PERMITTED_CLASSES))
35
+ end
36
+
37
+ def initialize
38
+ super
39
+ class_loader = Psych::ClassLoader::Restricted.new(PERMITTED_CLASSES.map(&:name), [])
40
+ @scanner = Psych::ScalarScanner.new(class_loader)
41
+ @documents = []
42
+ @containers = [@documents]
43
+ # Per open mapping: the key waiting for its value, and whether the mapping
44
+ # holds named examples.
45
+ @pending_keys = []
46
+ @examples_maps = []
47
+ end
48
+
49
+ def root
50
+ @documents.first
51
+ end
52
+
53
+ def scalar(value, anchor, tag, _plain, quoted, _style) # rubocop:disable Metrics/ParameterLists
54
+ supported!(anchor, tag, value)
55
+ value = @scanner.tokenize(value) unless quoted || key_next?
56
+ add(value)
57
+ end
58
+
59
+ def start_sequence(anchor, tag, _implicit, _style)
60
+ supported!(anchor, tag)
61
+ @containers.push(add([]))
62
+ end
63
+
64
+ def end_sequence
65
+ @containers.pop
66
+ end
67
+
68
+ def start_mapping(anchor, tag, _implicit, _style)
69
+ supported!(anchor, tag)
70
+ @examples_maps.push(@containers.last.is_a?(Hash) && @pending_keys.last == :examples)
71
+ @containers.push(add({}))
72
+ @pending_keys.push(NONE)
73
+ end
74
+
75
+ def end_mapping
76
+ @containers.pop
77
+ @pending_keys.pop
78
+ @examples_maps.pop
79
+ end
80
+
81
+ def alias(_anchor)
82
+ raise Unsupported
83
+ end
84
+
85
+ private
86
+
87
+ # A `<<` key merges its value into the mapping, so it is handed off too.
88
+ def supported!(anchor, tag, value = nil)
89
+ raise Unsupported if anchor || tag || value == '<<'
90
+ end
91
+
92
+ # A sequence inside a mapping pushes no pending key, so the mapping's NONE
93
+ # shows through it and the container is checked as well.
94
+ def key_next?
95
+ @pending_keys.last.equal?(NONE) && @containers.last.is_a?(Hash)
96
+ end
97
+
98
+ # Values arrive in document order: onto the open sequence, or as the key and
99
+ # then the value of the open mapping. The documents are the outermost
100
+ # sequence.
101
+ def add(value)
102
+ container = @containers.last
103
+ if container.is_a?(Array)
104
+ container << value
105
+ elsif @pending_keys.last.equal?(NONE)
106
+ @pending_keys[-1] = key_for(value)
107
+ else
108
+ container[@pending_keys[-1]] = value
109
+ @pending_keys[-1] = NONE
110
+ end
111
+ value
112
+ end
113
+
114
+ def key_for(key)
115
+ key = RSpec::OpenAPI::ExampleKey.normalize(key) if @examples_maps.last && key.is_a?(String)
116
+ key.to_sym
117
+ end
118
+ end
data/lib/rspec/openapi.rb CHANGED
@@ -9,6 +9,7 @@ require 'rspec/openapi/stream_parser'
9
9
  require 'rspec/openapi/record_builder'
10
10
  require 'rspec/openapi/exchange_recorder'
11
11
  require 'rspec/openapi/parallel_records'
12
+ require 'rspec/openapi/partial_run'
12
13
  require 'rspec/openapi/result_recorder'
13
14
  require 'rspec/openapi/schema_builder'
14
15
  require 'rspec/openapi/schema_file'
@@ -17,6 +18,8 @@ require 'rspec/openapi/schema_merger'
17
18
  require 'rspec/openapi/schema_cleaner'
18
19
  require 'rspec/openapi/schema_sorter'
19
20
  require 'rspec/openapi/key_transformer'
21
+ require 'rspec/openapi/yaml_dumper'
22
+ require 'rspec/openapi/yaml_loader'
20
23
  require 'rspec/openapi/shared_hooks'
21
24
  require 'rspec/openapi/extractors'
22
25
  require 'rspec/openapi/extractors/shared_extractor'
@@ -32,6 +35,11 @@ module RSpec::OpenAPI
32
35
  'text/event-stream',
33
36
  ].freeze
34
37
 
38
+ # Properties a server assigns by convention, for read_only_builder. Foreign
39
+ # keys such as `user_id` are left out, since clients send them.
40
+ DEFAULT_READ_ONLY_KEYS = ['id', 'uuid', 'created_at', 'updated_at'].freeze
41
+ DEFAULT_READ_ONLY_BUILDER = ->(_example, key) { DEFAULT_READ_ONLY_KEYS.include?(key) }
42
+
35
43
  class Config
36
44
  class << self
37
45
  attr_accessor :debug_enabled
@@ -52,6 +60,7 @@ module RSpec::OpenAPI
52
60
  @summary_builder = ->(example) { example.metadata[:summary] }
53
61
  @tags_builder = ->(example) { example.metadata[:tags] }
54
62
  @formats_builder = ->(example) { example.metadata[:formats] }
63
+ @read_only_builder = nil
55
64
  @info = {}
56
65
  @application_version = '1.0.0'
57
66
  @openapi_version = '3.2.0'
@@ -65,6 +74,8 @@ module RSpec::OpenAPI
65
74
  @ignored_path_params = [:controller, :action, :format]
66
75
  @ignored_paths = []
67
76
  @post_process_hook = nil
77
+ # true or false forces the mode. nil lets each run decide, see PartialRun.
78
+ @partial_update = { '1' => true, '0' => false }[ENV.fetch('OPENAPI_PARTIAL_UPDATE', nil)]
68
79
 
69
80
  # This is the configuraion override file name we look for within each path.
70
81
  @config_filename = 'rspec_openapi.rb'
@@ -80,6 +91,7 @@ module RSpec::OpenAPI
80
91
  :summary_builder,
81
92
  :tags_builder,
82
93
  :formats_builder,
94
+ :read_only_builder,
83
95
  :info,
84
96
  :application_version,
85
97
  :request_headers,
@@ -91,7 +103,8 @@ module RSpec::OpenAPI
91
103
  :path_records,
92
104
  :ignored_paths,
93
105
  :ignored_path_params,
94
- :post_process_hook
106
+ :post_process_hook,
107
+ :partial_update
95
108
 
96
109
  attr_reader :config_filename, :openapi_version
97
110
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-openapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.34.0
4
+ version: 0.36.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takashi Kokubun
@@ -78,6 +78,7 @@ files:
78
78
  - lib/rspec/openapi/nullable_converter.rb
79
79
  - lib/rspec/openapi/operation_converter.rb
80
80
  - lib/rspec/openapi/parallel_records.rb
81
+ - lib/rspec/openapi/partial_run.rb
81
82
  - lib/rspec/openapi/record.rb
82
83
  - lib/rspec/openapi/record_builder.rb
83
84
  - lib/rspec/openapi/result_recorder.rb
@@ -92,6 +93,8 @@ files:
92
93
  - lib/rspec/openapi/shared_hooks.rb
93
94
  - lib/rspec/openapi/stream_parser.rb
94
95
  - lib/rspec/openapi/version.rb
96
+ - lib/rspec/openapi/yaml_dumper.rb
97
+ - lib/rspec/openapi/yaml_loader.rb
95
98
  - rspec-openapi.gemspec
96
99
  homepage: https://github.com/exoego/rspec-openapi
97
100
  licenses:
@@ -99,7 +102,7 @@ licenses:
99
102
  metadata:
100
103
  homepage_uri: https://github.com/exoego/rspec-openapi
101
104
  source_code_uri: https://github.com/exoego/rspec-openapi
102
- changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.34.0
105
+ changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.36.0
103
106
  rubygems_mfa_required: 'true'
104
107
  rdoc_options: []
105
108
  require_paths:
@@ -115,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
115
118
  - !ruby/object:Gem::Version
116
119
  version: '0'
117
120
  requirements: []
118
- rubygems_version: 4.0.16
121
+ rubygems_version: 4.0.20
119
122
  specification_version: 4
120
123
  summary: Generate OpenAPI schema from RSpec request specs
121
124
  test_files: []