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
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
34
|
-
responses: { record.status.to_s => build_response(record) },
|
|
35
|
-
}
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
}
|
|
175
|
+
}
|
|
176
|
+
parameter.compact!
|
|
177
|
+
parameter
|
|
152
178
|
end
|
|
153
179
|
|
|
154
180
|
def build_response_headers(record)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
|
206
|
+
ctx = BuildContext.root(record, :request)
|
|
179
207
|
schema = build_property(record.request_params, ctx)
|
|
180
|
-
example = example_enabled?(record)
|
|
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
|
|
211
|
-
|
|
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[:
|
|
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.
|
|
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.
|
|
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&.
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
#
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
#
|
|
83
|
-
#
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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.
|
|
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(&
|
|
126
|
-
target_array.select! { |e| spec_identities.include?(
|
|
127
|
-
target_array.sort_by! { |param|
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
21
|
+
def edit
|
|
21
22
|
spec = read
|
|
22
|
-
|
|
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
|
-
|
|
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 =
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
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
|
-
|
|
67
|
+
comment.gsub(/^/, '# ').gsub(/^# \n/, "#\n")
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
def json?
|