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.
- checksums.yaml +4 -4
- data/README.md +101 -0
- data/lib/rspec/openapi/components_updater.rb +49 -24
- data/lib/rspec/openapi/extractors/shared_extractor.rb +42 -6
- data/lib/rspec/openapi/hash_helper.rb +125 -21
- data/lib/rspec/openapi/key_transformer.rb +28 -13
- data/lib/rspec/openapi/minitest_hooks.rb +2 -1
- data/lib/rspec/openapi/nullable_converter.rb +24 -19
- 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 +24 -10
- data/lib/rspec/openapi/rspec_hooks.rb +11 -3
- data/lib/rspec/openapi/schema_builder/build_context.rb +20 -6
- data/lib/rspec/openapi/schema_builder/variation_merger.rb +58 -20
- data/lib/rspec/openapi/schema_builder.rb +106 -64
- data/lib/rspec/openapi/schema_cleaner.rb +108 -62
- data/lib/rspec/openapi/schema_file.rb +27 -20
- data/lib/rspec/openapi/schema_merger.rb +142 -64
- data/lib/rspec/openapi/schema_sorter.rb +25 -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 +6 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return if option
|
|
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
|
-
|
|
220
|
-
|
|
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.
|
|
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.
|
|
36
|
-
deep_sort_hash!(
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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: []
|