rspec-openapi 0.1.0 → 0.18.4
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/.github/dependabot.yml +8 -0
- data/.github/release.yaml +24 -0
- data/.github/workflows/codeql-analysis.yml +39 -0
- data/.github/workflows/rubocop.yml +35 -0
- data/.github/workflows/test.yml +46 -0
- data/.rspec +2 -1
- data/.rubocop.yml +25 -0
- data/.rubocop_todo.yml +52 -0
- data/.simplecov_spawn.rb +16 -0
- data/CHANGELOG.md +287 -0
- data/Gemfile +28 -2
- data/README.md +267 -28
- data/Rakefile +8 -4
- data/bin/console +4 -3
- data/lib/rspec/openapi/components_updater.rb +98 -0
- data/lib/rspec/openapi/default_schema.rb +14 -2
- data/lib/rspec/openapi/extractors/hanami.rb +110 -0
- data/lib/rspec/openapi/extractors/rack.rb +31 -0
- data/lib/rspec/openapi/extractors/rails.rb +66 -0
- data/lib/rspec/openapi/extractors.rb +5 -0
- data/lib/rspec/openapi/hash_helper.rb +43 -0
- data/lib/rspec/openapi/key_transformer.rb +25 -0
- data/lib/rspec/openapi/minitest_hooks.rb +50 -0
- data/lib/rspec/openapi/record.rb +13 -3
- data/lib/rspec/openapi/record_builder.rb +65 -21
- data/lib/rspec/openapi/result_recorder.rb +63 -0
- data/lib/rspec/openapi/rspec_hooks.rb +21 -0
- data/lib/rspec/openapi/schema_builder.rb +166 -41
- data/lib/rspec/openapi/schema_cleaner.rb +129 -0
- data/lib/rspec/openapi/schema_file.rb +25 -3
- data/lib/rspec/openapi/schema_merger.rb +91 -21
- data/lib/rspec/openapi/schema_sorter.rb +35 -0
- data/lib/rspec/openapi/shared_hooks.rb +15 -0
- data/lib/rspec/openapi/version.rb +3 -1
- data/lib/rspec/openapi.rb +88 -2
- data/rspec-openapi.gemspec +19 -11
- data/scripts/rspec +11 -0
- data/scripts/rspec_with_simplecov +48 -0
- data/test.png +0 -0
- metadata +69 -15
- data/.travis.yml +0 -6
- data/lib/rspec/openapi/hooks.rb +0 -24
|
@@ -1,23 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
2
4
|
# @param [RSpec::OpenAPI::Record] record
|
|
3
5
|
# @return [Hash]
|
|
4
6
|
def build(record)
|
|
7
|
+
response = {
|
|
8
|
+
description: record.description,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
response_headers = build_response_headers(record)
|
|
12
|
+
response[:headers] = response_headers unless response_headers.empty?
|
|
13
|
+
|
|
14
|
+
if record.response_body
|
|
15
|
+
disposition = normalize_content_disposition(record.response_content_disposition)
|
|
16
|
+
|
|
17
|
+
has_content = !normalize_content_type(record.response_content_type).nil?
|
|
18
|
+
if has_content
|
|
19
|
+
response[:content] = {
|
|
20
|
+
normalize_content_type(record.response_content_type) => {
|
|
21
|
+
schema: build_property(record.response_body, disposition: disposition),
|
|
22
|
+
example: response_example(record, disposition: disposition),
|
|
23
|
+
}.compact,
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
http_method = record.http_method.downcase
|
|
5
29
|
{
|
|
6
30
|
paths: {
|
|
7
|
-
record.path => {
|
|
8
|
-
|
|
9
|
-
summary:
|
|
31
|
+
normalize_path(record.path) => {
|
|
32
|
+
http_method => {
|
|
33
|
+
summary: record.summary,
|
|
34
|
+
tags: record.tags,
|
|
35
|
+
operationId: record.operation_id,
|
|
36
|
+
security: record.security,
|
|
37
|
+
deprecated: record.deprecated ? true : nil,
|
|
10
38
|
parameters: build_parameters(record),
|
|
11
|
-
requestBody: build_request_body(record),
|
|
39
|
+
requestBody: include_nil_request_body?(http_method) ? nil : build_request_body(record),
|
|
12
40
|
responses: {
|
|
13
|
-
record.status.to_s =>
|
|
14
|
-
description: record.description,
|
|
15
|
-
content: {
|
|
16
|
-
normalize_content_type(record.response_content_type) => {
|
|
17
|
-
schema: build_property(record.response_body),
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
},
|
|
41
|
+
record.status.to_s => response,
|
|
21
42
|
},
|
|
22
43
|
}.compact,
|
|
23
44
|
},
|
|
@@ -27,72 +48,140 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
27
48
|
|
|
28
49
|
private
|
|
29
50
|
|
|
30
|
-
def
|
|
31
|
-
|
|
51
|
+
def include_nil_request_body?(http_method)
|
|
52
|
+
%w[delete get].include?(http_method)
|
|
53
|
+
end
|
|
32
54
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
def enrich_with_required_keys(obj)
|
|
56
|
+
obj[:required] = obj[:properties]&.keys || []
|
|
57
|
+
obj
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def response_example(record, disposition:)
|
|
61
|
+
return nil if !example_enabled? || disposition
|
|
62
|
+
|
|
63
|
+
record.response_body
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def example_enabled?
|
|
67
|
+
RSpec::OpenAPI.enable_example
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_parameters(record)
|
|
71
|
+
path_params = record.path_params.map do |key, value|
|
|
72
|
+
{
|
|
73
|
+
name: build_parameter_name(key, value),
|
|
37
74
|
in: 'path',
|
|
75
|
+
required: true,
|
|
38
76
|
schema: build_property(try_cast(value)),
|
|
39
|
-
|
|
77
|
+
example: (try_cast(value) if example_enabled?),
|
|
78
|
+
}.compact
|
|
40
79
|
end
|
|
41
80
|
|
|
42
|
-
record.query_params.
|
|
43
|
-
|
|
44
|
-
name: key
|
|
81
|
+
query_params = record.query_params.map do |key, value|
|
|
82
|
+
{
|
|
83
|
+
name: build_parameter_name(key, value),
|
|
45
84
|
in: 'query',
|
|
85
|
+
required: record.required_request_params.include?(key),
|
|
86
|
+
schema: build_property(try_cast(value)),
|
|
87
|
+
example: (try_cast(value) if example_enabled?),
|
|
88
|
+
}.compact
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
header_params = record.request_headers.map do |key, value|
|
|
92
|
+
{
|
|
93
|
+
name: build_parameter_name(key, value),
|
|
94
|
+
in: 'header',
|
|
95
|
+
required: true,
|
|
46
96
|
schema: build_property(try_cast(value)),
|
|
47
|
-
|
|
97
|
+
example: (try_cast(value) if example_enabled?),
|
|
98
|
+
}.compact
|
|
48
99
|
end
|
|
49
100
|
|
|
101
|
+
parameters = path_params + query_params + header_params
|
|
102
|
+
|
|
50
103
|
return nil if parameters.empty?
|
|
104
|
+
|
|
51
105
|
parameters
|
|
52
106
|
end
|
|
53
107
|
|
|
108
|
+
def build_response_headers(record)
|
|
109
|
+
headers = {}
|
|
110
|
+
|
|
111
|
+
record.response_headers.each do |key, value|
|
|
112
|
+
headers[key] = {
|
|
113
|
+
schema: build_property(try_cast(value)),
|
|
114
|
+
}.compact
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
headers
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_parameter_name(key, value)
|
|
121
|
+
key = key.to_s
|
|
122
|
+
if value.is_a?(Hash) && (value_keys = value.keys).size == 1
|
|
123
|
+
value_key = value_keys.first
|
|
124
|
+
build_parameter_name("#{key}[#{value_key}]", value[value_key])
|
|
125
|
+
else
|
|
126
|
+
key
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
54
130
|
def build_request_body(record)
|
|
55
131
|
return nil if record.request_content_type.nil?
|
|
56
|
-
return nil if record.
|
|
132
|
+
return nil if record.status >= 400
|
|
57
133
|
|
|
58
134
|
{
|
|
59
135
|
content: {
|
|
60
136
|
normalize_content_type(record.request_content_type) => {
|
|
61
137
|
schema: build_property(record.request_params),
|
|
62
|
-
|
|
63
|
-
|
|
138
|
+
example: (build_example(record.request_params) if example_enabled?),
|
|
139
|
+
}.compact,
|
|
140
|
+
},
|
|
64
141
|
}
|
|
65
142
|
end
|
|
66
143
|
|
|
67
|
-
def build_property(value)
|
|
68
|
-
property =
|
|
144
|
+
def build_property(value, disposition: nil)
|
|
145
|
+
property = build_type(value, disposition)
|
|
146
|
+
|
|
69
147
|
case value
|
|
70
148
|
when Array
|
|
71
|
-
property[:items] =
|
|
149
|
+
property[:items] = if value.empty?
|
|
150
|
+
{} # unknown
|
|
151
|
+
else
|
|
152
|
+
build_property(value.first)
|
|
153
|
+
end
|
|
72
154
|
when Hash
|
|
73
155
|
property[:properties] = {}.tap do |properties|
|
|
74
156
|
value.each do |key, v|
|
|
75
157
|
properties[key] = build_property(v)
|
|
76
158
|
end
|
|
77
159
|
end
|
|
160
|
+
property = enrich_with_required_keys(property)
|
|
78
161
|
end
|
|
79
162
|
property
|
|
80
163
|
end
|
|
81
164
|
|
|
82
|
-
def build_type(value)
|
|
165
|
+
def build_type(value, disposition)
|
|
166
|
+
return { type: 'string', format: 'binary' } if disposition
|
|
167
|
+
|
|
83
168
|
case value
|
|
84
169
|
when String
|
|
85
|
-
'string'
|
|
170
|
+
{ type: 'string' }
|
|
86
171
|
when Integer
|
|
87
|
-
'integer'
|
|
172
|
+
{ type: 'integer' }
|
|
173
|
+
when Float
|
|
174
|
+
{ type: 'number', format: 'float' }
|
|
88
175
|
when TrueClass, FalseClass
|
|
89
|
-
'boolean'
|
|
176
|
+
{ type: 'boolean' }
|
|
90
177
|
when Array
|
|
91
|
-
'array'
|
|
178
|
+
{ type: 'array' }
|
|
92
179
|
when Hash
|
|
93
|
-
'object'
|
|
180
|
+
{ type: 'object' }
|
|
181
|
+
when ActionDispatch::Http::UploadedFile
|
|
182
|
+
{ type: 'string', format: 'binary' }
|
|
94
183
|
when NilClass
|
|
95
|
-
|
|
184
|
+
{ nullable: true }
|
|
96
185
|
else
|
|
97
186
|
raise NotImplementedError, "type detection is not implemented for: #{value.inspect}"
|
|
98
187
|
end
|
|
@@ -100,14 +189,50 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
100
189
|
|
|
101
190
|
# Convert an always-String param to an appropriate type
|
|
102
191
|
def try_cast(value)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
192
|
+
Integer(value)
|
|
193
|
+
rescue TypeError, ArgumentError
|
|
194
|
+
value
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def build_example(value)
|
|
198
|
+
return nil if value.nil?
|
|
199
|
+
|
|
200
|
+
value = value.dup
|
|
201
|
+
adjust_params(value)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def adjust_params(value)
|
|
205
|
+
value.each do |key, v|
|
|
206
|
+
case v
|
|
207
|
+
when ActionDispatch::Http::UploadedFile
|
|
208
|
+
value[key] = v.original_filename
|
|
209
|
+
when Hash
|
|
210
|
+
adjust_params(v)
|
|
211
|
+
when Array
|
|
212
|
+
result = v.map do |item|
|
|
213
|
+
case item
|
|
214
|
+
when ActionDispatch::Http::UploadedFile
|
|
215
|
+
item.original_filename
|
|
216
|
+
when Hash
|
|
217
|
+
adjust_params(item)
|
|
218
|
+
else
|
|
219
|
+
item
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
value[key] = result
|
|
223
|
+
end
|
|
107
224
|
end
|
|
108
225
|
end
|
|
109
226
|
|
|
227
|
+
def normalize_path(path)
|
|
228
|
+
path.gsub(%r{/:([^:/]+)}, '/{\1}')
|
|
229
|
+
end
|
|
230
|
+
|
|
110
231
|
def normalize_content_type(content_type)
|
|
111
|
-
content_type
|
|
232
|
+
content_type&.sub(/;.+\z/, '')
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def normalize_content_disposition(content_disposition)
|
|
236
|
+
content_disposition&.sub(/;.+\z/, '')
|
|
112
237
|
end
|
|
113
238
|
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# For Ruby 3.0+
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
require_relative 'hash_helper'
|
|
7
|
+
|
|
8
|
+
class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
9
|
+
# Cleanup the properties, of component schemas, that exists in the base but not in the spec.
|
|
10
|
+
#
|
|
11
|
+
# @param [Hash] base
|
|
12
|
+
# @param [Hash] spec
|
|
13
|
+
def cleanup_components_schemas!(base, spec)
|
|
14
|
+
cleanup_hash!(base, spec, 'components.schemas.*')
|
|
15
|
+
cleanup_hash!(base, spec, 'components.schemas.*.properties.*')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Cleanup specific elements that exists in the base but not in the spec
|
|
19
|
+
#
|
|
20
|
+
# @param [Hash] base
|
|
21
|
+
# @param [Hash] spec
|
|
22
|
+
def cleanup!(base, spec)
|
|
23
|
+
# cleanup URLs
|
|
24
|
+
cleanup_hash!(base, spec, 'paths.*')
|
|
25
|
+
|
|
26
|
+
# cleanup HTTP methods
|
|
27
|
+
cleanup_hash!(base, spec, 'paths.*.*')
|
|
28
|
+
|
|
29
|
+
# cleanup parameters
|
|
30
|
+
cleanup_array!(base, spec, 'paths.*.*.parameters', %i[name in])
|
|
31
|
+
|
|
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
|
+
|
|
36
|
+
# cleanup responses
|
|
37
|
+
cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.schema.properties.*')
|
|
38
|
+
cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.example.*')
|
|
39
|
+
base
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cleanup_conflicting_security_parameters!(base)
|
|
43
|
+
security_schemes = base.dig(:components, :securitySchemes) || {}
|
|
44
|
+
|
|
45
|
+
return if security_schemes.empty?
|
|
46
|
+
|
|
47
|
+
paths_to_security_definitions = RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'paths', 'security')
|
|
48
|
+
|
|
49
|
+
paths_to_security_definitions.each do |path|
|
|
50
|
+
parent_path_definition = base.dig(*path.take(path.length - 1))
|
|
51
|
+
|
|
52
|
+
security_schemes.each do |security_scheme_name, security_scheme|
|
|
53
|
+
remove_parameters_conflicting_with_security_sceheme!(
|
|
54
|
+
parent_path_definition, security_scheme, security_scheme_name,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cleanup_empty_required_array!(base)
|
|
61
|
+
paths_to_objects = [
|
|
62
|
+
*RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties'),
|
|
63
|
+
*RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'paths', 'properties'),
|
|
64
|
+
]
|
|
65
|
+
paths_to_objects.each do |path|
|
|
66
|
+
parent = base.dig(*path.take(path.length - 1))
|
|
67
|
+
# "required" array must not be present if empty
|
|
68
|
+
parent.delete(:required) if parent[:required] && parent[:required].empty?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def remove_parameters_conflicting_with_security_sceheme!(path_definition, security_scheme, security_scheme_name)
|
|
75
|
+
return unless path_definition[:security]
|
|
76
|
+
return unless path_definition[:parameters]
|
|
77
|
+
return unless path_definition.dig(:security, 0).keys.include?(security_scheme_name)
|
|
78
|
+
|
|
79
|
+
path_definition[:parameters].reject! do |parameter|
|
|
80
|
+
parameter[:in] == security_scheme[:in] && # same location (ie. header)
|
|
81
|
+
parameter[:name] == security_scheme[:name] # same name (ie. AUTHORIZATION)
|
|
82
|
+
end
|
|
83
|
+
path_definition.delete(:parameters) if path_definition[:parameters].empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cleanup_array!(base, spec, selector, fields_for_identity = [])
|
|
87
|
+
marshal = lambda do |obj|
|
|
88
|
+
Marshal.dump(slice(obj, fields_for_identity))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
|
|
92
|
+
target_array = base.dig(*paths)
|
|
93
|
+
spec_array = spec.dig(*paths)
|
|
94
|
+
next unless target_array.is_a?(Array) && spec_array.is_a?(Array)
|
|
95
|
+
|
|
96
|
+
spec_identities = Set.new(spec_array.map(&marshal))
|
|
97
|
+
target_array.select! { |e| spec_identities.include?(marshal.call(e)) }
|
|
98
|
+
target_array.sort_by! { |param| fields_for_identity.map { |f| param[f] }.join('-') }
|
|
99
|
+
# Keep the last duplicate to produce the result stably
|
|
100
|
+
deduplicated = target_array.reverse.uniq { |param| slice(param, fields_for_identity) }.reverse
|
|
101
|
+
target_array.replace(deduplicated)
|
|
102
|
+
end
|
|
103
|
+
base
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def cleanup_hash!(base, spec, selector)
|
|
107
|
+
RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
|
|
108
|
+
exist_in_base = !base.dig(*paths).nil?
|
|
109
|
+
not_in_spec = spec.dig(*paths).nil?
|
|
110
|
+
if exist_in_base && not_in_spec
|
|
111
|
+
if paths.size == 1
|
|
112
|
+
base.delete(paths.last)
|
|
113
|
+
else
|
|
114
|
+
parent_node = base.dig(*paths[0..-2])
|
|
115
|
+
parent_node.delete(paths.last)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
base
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def slice(obj, fields_for_identity)
|
|
123
|
+
if fields_for_identity.any?
|
|
124
|
+
obj.slice(*fields_for_identity)
|
|
125
|
+
else
|
|
126
|
+
obj
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'fileutils'
|
|
2
4
|
require 'yaml'
|
|
5
|
+
require 'json'
|
|
3
6
|
|
|
4
7
|
# TODO: Support JSON
|
|
5
8
|
class RSpec::OpenAPI::SchemaFile
|
|
@@ -12,7 +15,7 @@ class RSpec::OpenAPI::SchemaFile
|
|
|
12
15
|
spec = read
|
|
13
16
|
block.call(spec)
|
|
14
17
|
ensure
|
|
15
|
-
write(spec)
|
|
18
|
+
write(RSpec::OpenAPI::KeyTransformer.stringify(spec))
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
private
|
|
@@ -20,12 +23,31 @@ class RSpec::OpenAPI::SchemaFile
|
|
|
20
23
|
# @return [Hash]
|
|
21
24
|
def read
|
|
22
25
|
return {} unless File.exist?(@path)
|
|
23
|
-
|
|
26
|
+
|
|
27
|
+
RSpec::OpenAPI::KeyTransformer.symbolize(YAML.safe_load(File.read(@path))) # this can also parse JSON
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
# @param [Hash] spec
|
|
27
31
|
def write(spec)
|
|
28
32
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
29
|
-
|
|
33
|
+
output =
|
|
34
|
+
if json?
|
|
35
|
+
JSON.pretty_generate(spec)
|
|
36
|
+
else
|
|
37
|
+
prepend_comment(YAML.dump(spec))
|
|
38
|
+
end
|
|
39
|
+
File.write(@path, output)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def prepend_comment(content)
|
|
43
|
+
return content if RSpec::OpenAPI.comment.nil?
|
|
44
|
+
|
|
45
|
+
comment = RSpec::OpenAPI.comment.dup
|
|
46
|
+
comment << "\n" unless comment.end_with?("\n")
|
|
47
|
+
"#{comment.gsub(/^/, '# ').gsub(/^# \n/, "#\n")}#{content}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def json?
|
|
51
|
+
File.extname(@path) == '.json'
|
|
30
52
|
end
|
|
31
53
|
end
|
|
@@ -1,37 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
class << RSpec::OpenAPI::SchemaMerger = Object.new
|
|
2
4
|
# @param [Hash] base
|
|
3
5
|
# @param [Hash] spec
|
|
4
|
-
def
|
|
5
|
-
spec =
|
|
6
|
-
|
|
6
|
+
def merge!(base, spec)
|
|
7
|
+
spec = RSpec::OpenAPI::KeyTransformer.symbolize(spec)
|
|
8
|
+
base.replace(RSpec::OpenAPI::KeyTransformer.symbolize(base))
|
|
9
|
+
merge_schema!(base, spec)
|
|
7
10
|
end
|
|
8
11
|
|
|
9
12
|
private
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
spec
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
# Not doing `base.replace(deep_merge(base, spec))` to preserve key orders.
|
|
15
|
+
# Also this needs to be aware of OpenAPI details because a Hash-like structure
|
|
16
|
+
# may be an array whose Hash elements have a key name.
|
|
17
|
+
#
|
|
18
|
+
# TODO: Should we probably force-merge `summary` regardless of manual modifications?
|
|
19
|
+
def merge_schema!(base, spec)
|
|
20
|
+
if (options = base[:oneOf])
|
|
21
|
+
merge_closest_match!(options, spec)
|
|
22
|
+
|
|
23
|
+
return base
|
|
21
24
|
end
|
|
22
|
-
end
|
|
23
25
|
|
|
24
|
-
# Not doing `base.replace(deep_merge(base, spec))` to preserve key orders
|
|
25
|
-
# TODO: Perform more intelligent merges like rerouting edits / merging types
|
|
26
|
-
# Should we probably force-merge `summary` regardless of manual modifications?
|
|
27
|
-
def deep_reverse_merge!(base, spec)
|
|
28
26
|
spec.each do |key, value|
|
|
29
27
|
if base[key].is_a?(Hash) && value.is_a?(Hash)
|
|
30
|
-
|
|
31
|
-
elsif
|
|
32
|
-
|
|
28
|
+
merge_schema!(base[key], value) unless base[key].key?(:$ref)
|
|
29
|
+
elsif base[key].is_a?(Array) && value.is_a?(Array)
|
|
30
|
+
# parameters need to be merged as if `name` and `in` were the Hash keys.
|
|
31
|
+
merge_arrays(base, key, value)
|
|
32
|
+
else
|
|
33
|
+
# do not ADD `properties` or `required` fields if `additionalProperties` field is present
|
|
34
|
+
base[key] = value unless base.key?(:additionalProperties) && %i[properties required].include?(key)
|
|
33
35
|
end
|
|
34
36
|
end
|
|
35
37
|
base
|
|
36
38
|
end
|
|
39
|
+
|
|
40
|
+
def merge_arrays(base, key, value)
|
|
41
|
+
base[key] = case key
|
|
42
|
+
when :parameters
|
|
43
|
+
merge_parameters(base, key, value)
|
|
44
|
+
when :required
|
|
45
|
+
# Preserve properties that appears in all test cases
|
|
46
|
+
value & base[key]
|
|
47
|
+
else
|
|
48
|
+
# last one wins
|
|
49
|
+
value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def merge_parameters(base, key, value)
|
|
54
|
+
all_parameters = value | base[key]
|
|
55
|
+
|
|
56
|
+
unique_base_parameters = build_unique_params(base, key)
|
|
57
|
+
|
|
58
|
+
all_parameters = all_parameters.map do |parameter|
|
|
59
|
+
base_parameter = unique_base_parameters[[parameter[:name], parameter[:in]]] || {}
|
|
60
|
+
base_parameter ? base_parameter.merge(parameter) : parameter
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
all_parameters.uniq! { |param| param.slice(:name, :in) }
|
|
64
|
+
base[key] = all_parameters
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_unique_params(base, key)
|
|
68
|
+
base[key].each_with_object({}) do |parameter, hash|
|
|
69
|
+
hash[[parameter[:name], parameter[:in]]] = parameter
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
SIMILARITY_THRESHOLD = 0.5
|
|
74
|
+
|
|
75
|
+
def merge_closest_match!(options, spec)
|
|
76
|
+
score, option = options.map { |option| [similarity(option, spec), option] }.max_by(&:first)
|
|
77
|
+
|
|
78
|
+
return if option&.key?(:$ref)
|
|
79
|
+
|
|
80
|
+
if score.to_f > SIMILARITY_THRESHOLD
|
|
81
|
+
merge_schema!(option, spec)
|
|
82
|
+
else
|
|
83
|
+
options.push(spec)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def similarity(first, second)
|
|
88
|
+
return 1 if first == second
|
|
89
|
+
|
|
90
|
+
score =
|
|
91
|
+
case [first.class, second.class]
|
|
92
|
+
when [Array, Array]
|
|
93
|
+
(first & second).size / [first.size, second.size].max.to_f
|
|
94
|
+
when [Hash, Hash]
|
|
95
|
+
return 1 if first.merge(second).key?(:$ref)
|
|
96
|
+
|
|
97
|
+
intersection = first.keys & second.keys
|
|
98
|
+
total_size = [first.size, second.size].max.to_f
|
|
99
|
+
|
|
100
|
+
intersection.sum { |key| similarity(first[key], second[key]) } / total_size
|
|
101
|
+
else
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
score.finite? ? score : 0
|
|
106
|
+
end
|
|
37
107
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class << RSpec::OpenAPI::SchemaSorter = Object.new
|
|
4
|
+
# Sort some unpredictably ordered properties in a lexicographical manner to make the order more predictable.
|
|
5
|
+
#
|
|
6
|
+
# @param [Hash|Array]
|
|
7
|
+
def deep_sort!(spec)
|
|
8
|
+
# paths
|
|
9
|
+
deep_sort_by_selector!(spec, 'paths')
|
|
10
|
+
|
|
11
|
+
# methods
|
|
12
|
+
deep_sort_by_selector!(spec, 'paths.*')
|
|
13
|
+
|
|
14
|
+
# response status code
|
|
15
|
+
deep_sort_by_selector!(spec, 'paths.*.*.responses')
|
|
16
|
+
|
|
17
|
+
# content-type
|
|
18
|
+
deep_sort_by_selector!(spec, 'paths.*.*.responses.*.content')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @param [Hash] base
|
|
24
|
+
# @param [String] selector
|
|
25
|
+
def deep_sort_by_selector!(base, selector)
|
|
26
|
+
RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
|
|
27
|
+
deep_sort_hash!(base.dig(*paths))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def deep_sort_hash!(hash)
|
|
32
|
+
sorted = hash.entries.sort_by { |k, _| k.to_s }.to_h.transform_keys(&:to_sym)
|
|
33
|
+
hash.replace(sorted)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SharedHooks
|
|
4
|
+
def self.find_extractor
|
|
5
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
6
|
+
RSpec::OpenAPI::Extractors::Rails
|
|
7
|
+
elsif defined?(Hanami) && Hanami.respond_to?(:app) && Hanami.app?
|
|
8
|
+
RSpec::OpenAPI::Extractors::Hanami
|
|
9
|
+
# elsif defined?(Roda)
|
|
10
|
+
# some Roda extractor
|
|
11
|
+
else
|
|
12
|
+
RSpec::OpenAPI::Extractors::Rack
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|