rswag-specs-2.1 2.9.1.ruby21
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/Rakefile +23 -0
- data/lib/generators/rspec/USAGE +9 -0
- data/lib/generators/rspec/swagger_generator.rb +25 -0
- data/lib/generators/rspec/templates/spec.rb +34 -0
- data/lib/generators/rswag/specs/install/USAGE +8 -0
- data/lib/generators/rswag/specs/install/install_generator.rb +15 -0
- data/lib/generators/rswag/specs/install/templates/swagger_helper.rb +43 -0
- data/lib/rswag/route_parser.rb +60 -0
- data/lib/rswag/specs/configuration.rb +66 -0
- data/lib/rswag/specs/example_group_helpers.rb +150 -0
- data/lib/rswag/specs/example_helpers.rb +34 -0
- data/lib/rswag/specs/extended_schema.rb +23 -0
- data/lib/rswag/specs/railtie.rb +15 -0
- data/lib/rswag/specs/request_factory.rb +304 -0
- data/lib/rswag/specs/response_validator.rb +103 -0
- data/lib/rswag/specs/swagger_formatter.rb +226 -0
- data/lib/rswag/specs.rb +30 -0
- data/lib/tasks/rswag-specs_tasks.rake +31 -0
- metadata +155 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "active_support"
|
|
3
|
+
require 'active_support/core_ext/hash/slice'
|
|
4
|
+
require 'active_support/core_ext/hash/conversions'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module Rswag
|
|
8
|
+
module Specs
|
|
9
|
+
class RequestFactory
|
|
10
|
+
def initialize(config = ::Rswag::Specs.config)
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build_request(metadata, example)
|
|
15
|
+
swagger_doc = @config.get_swagger_doc(metadata[:swagger_doc])
|
|
16
|
+
parameters = expand_parameters(metadata, swagger_doc, example)
|
|
17
|
+
|
|
18
|
+
{}.tap do |request|
|
|
19
|
+
add_verb(request, metadata)
|
|
20
|
+
add_path(request, metadata, swagger_doc, parameters, example)
|
|
21
|
+
add_headers(request, metadata, swagger_doc, parameters, example)
|
|
22
|
+
add_payload(request, parameters, example)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def expand_parameters(metadata, swagger_doc, example)
|
|
29
|
+
operation_params = metadata[:operation][:parameters] || []
|
|
30
|
+
path_item_params = metadata[:path_item][:parameters] || []
|
|
31
|
+
security_params = derive_security_params(metadata, swagger_doc)
|
|
32
|
+
|
|
33
|
+
# NOTE: Use of + instead of concat to avoid mutation of the metadata object
|
|
34
|
+
(operation_params + path_item_params + security_params)
|
|
35
|
+
.map { |p| p['$ref'] ? resolve_parameter(p['$ref'], swagger_doc) : p }
|
|
36
|
+
.uniq { |p| p[:name] }
|
|
37
|
+
.reject { |p| p[:required] == false && !example.respond_to?(extract_getter(p)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def derive_security_params(metadata, swagger_doc)
|
|
41
|
+
requirements = metadata[:operation][:security] || swagger_doc[:security] || []
|
|
42
|
+
scheme_names = requirements.flat_map(&:keys)
|
|
43
|
+
schemes = security_version(scheme_names, swagger_doc)
|
|
44
|
+
|
|
45
|
+
schemes.map do |scheme|
|
|
46
|
+
param = (scheme[:type] == :apiKey) ? scheme.slice(:name, :in) : { name: 'Authorization', in: :header }
|
|
47
|
+
param.merge(type: :string, required: requirements.one?)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def security_version(scheme_names, swagger_doc)
|
|
52
|
+
if doc_version(swagger_doc).start_with?('2')
|
|
53
|
+
(swagger_doc[:securityDefinitions] || {}).slice(*scheme_names).values
|
|
54
|
+
else # Openapi3
|
|
55
|
+
if swagger_doc.key?(:securityDefinitions)
|
|
56
|
+
ActiveSupport::Deprecation.warn('Rswag::Specs: WARNING: securityDefinitions is replaced in OpenAPI3! Rename to components/securitySchemes (in swagger_helper.rb)')
|
|
57
|
+
swagger_doc[:components] ||= { securitySchemes: swagger_doc[:securityDefinitions] }
|
|
58
|
+
swagger_doc.delete(:securityDefinitions)
|
|
59
|
+
end
|
|
60
|
+
components = swagger_doc[:components] || {}
|
|
61
|
+
(components[:securitySchemes] || {}).slice(*scheme_names).values
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_parameter(ref, swagger_doc)
|
|
66
|
+
key = key_version(ref, swagger_doc)
|
|
67
|
+
definitions = definition_version(swagger_doc)
|
|
68
|
+
raise "Referenced parameter '#{ref}' must be defined" unless definitions && definitions[key]
|
|
69
|
+
|
|
70
|
+
definitions[key]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def key_version(ref, swagger_doc)
|
|
74
|
+
if doc_version(swagger_doc).start_with?('2')
|
|
75
|
+
ref.sub('#/parameters/', '').to_sym
|
|
76
|
+
else # Openapi3
|
|
77
|
+
if ref.start_with?('#/parameters/')
|
|
78
|
+
ActiveSupport::Deprecation.warn('Rswag::Specs: WARNING: #/parameters/ refs are replaced in OpenAPI3! Rename to #/components/parameters/')
|
|
79
|
+
ref.sub('#/parameters/', '').to_sym
|
|
80
|
+
else
|
|
81
|
+
ref.sub('#/components/parameters/', '').to_sym
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def definition_version(swagger_doc)
|
|
87
|
+
if doc_version(swagger_doc).start_with?('2')
|
|
88
|
+
swagger_doc[:parameters]
|
|
89
|
+
else # Openapi3
|
|
90
|
+
if swagger_doc.key?(:parameters)
|
|
91
|
+
ActiveSupport::Deprecation.warn('Rswag::Specs: WARNING: parameters is replaced in OpenAPI3! Rename to components/parameters (in swagger_helper.rb)')
|
|
92
|
+
swagger_doc[:parameters]
|
|
93
|
+
else
|
|
94
|
+
components = swagger_doc[:components] || {}
|
|
95
|
+
components[:parameters]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def add_verb(request, metadata)
|
|
101
|
+
request[:verb] = metadata[:operation][:verb]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def base_path_from_servers(swagger_doc, use_server = :default)
|
|
105
|
+
return '' if swagger_doc[:servers].nil? || swagger_doc[:servers].empty?
|
|
106
|
+
server = swagger_doc[:servers].first
|
|
107
|
+
variables = {}
|
|
108
|
+
server.fetch(:variables, {}).each_pair { |k,v| variables[k] = v[use_server] }
|
|
109
|
+
base_path = server[:url].gsub(/\{(.*?)\}/) { variables[$1.to_sym] }
|
|
110
|
+
URI(base_path).path
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def add_path(request, metadata, swagger_doc, parameters, example)
|
|
114
|
+
open_api_3_doc = doc_version(swagger_doc).start_with?('3')
|
|
115
|
+
uses_base_path = swagger_doc[:basePath].present?
|
|
116
|
+
|
|
117
|
+
if open_api_3_doc && uses_base_path
|
|
118
|
+
ActiveSupport::Deprecation.warn('Rswag::Specs: WARNING: basePath is replaced in OpenAPI3! Update your swagger_helper.rb')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if uses_base_path
|
|
122
|
+
template = (swagger_doc[:basePath] || '') + metadata[:path_item][:template]
|
|
123
|
+
else # OpenAPI 3
|
|
124
|
+
template = base_path_from_servers(swagger_doc) + metadata[:path_item][:template]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
request[:path] = template.tap do |path_template|
|
|
128
|
+
parameters.select { |p| p[:in] == :path }.each do |p|
|
|
129
|
+
unless example.respond_to?(extract_getter(p))
|
|
130
|
+
raise ArgumentError.new("`#{p[:name].to_s}` parameter key present, but not defined within example group"\
|
|
131
|
+
"(i. e `it` or `let` block)")
|
|
132
|
+
end
|
|
133
|
+
path_template.gsub!("{#{p[:name]}}", example.send(extract_getter(p)).to_s)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
parameters.select { |p| p[:in] == :query }.each_with_index do |p, i|
|
|
137
|
+
path_template.concat(i.zero? ? '?' : '&')
|
|
138
|
+
path_template.concat(build_query_string_part(p, example.send(extract_getter(p)), swagger_doc))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_query_string_part(param, value, swagger_doc)
|
|
144
|
+
name = param[:name]
|
|
145
|
+
|
|
146
|
+
# OAS 3: https://swagger.io/docs/specification/serialization/
|
|
147
|
+
if swagger_doc && doc_version(swagger_doc).start_with?('3') && param[:schema]
|
|
148
|
+
# style = param[:style]&.to_sym || :form
|
|
149
|
+
style = param[:style] ? param[:style].to_sym : :form
|
|
150
|
+
explode = param[:explode].nil? ? true : param[:explode]
|
|
151
|
+
|
|
152
|
+
# case param[:schema][:type]&.to_sym
|
|
153
|
+
case param[:schema][:type] ? param[:schema][:type].to_sym : nil
|
|
154
|
+
when :object
|
|
155
|
+
case style
|
|
156
|
+
when :deepObject
|
|
157
|
+
return { name => value }.to_query
|
|
158
|
+
when :form
|
|
159
|
+
if explode
|
|
160
|
+
return value.to_query
|
|
161
|
+
else
|
|
162
|
+
return "#{CGI.escape(name.to_s)}=" + value.to_a.flatten.map{|v| CGI.escape(v.to_s) }.join(',')
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
when :array
|
|
166
|
+
case explode
|
|
167
|
+
when true
|
|
168
|
+
return value.to_a.flatten.map{|v| "#{CGI.escape(name.to_s)}=#{CGI.escape(v.to_s)}"}.join('&')
|
|
169
|
+
else
|
|
170
|
+
separator = case style
|
|
171
|
+
when :form then ','
|
|
172
|
+
when :spaceDelimited then '%20'
|
|
173
|
+
when :pipeDelimited then '|'
|
|
174
|
+
end
|
|
175
|
+
return "#{CGI.escape(name.to_s)}=" + value.to_a.flatten.map{|v| CGI.escape(v.to_s) }.join(separator)
|
|
176
|
+
end
|
|
177
|
+
else
|
|
178
|
+
return "#{name}=#{value}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# type = param[:type] || param.dig(:schema, :type)
|
|
183
|
+
type = param[:type] || (param[:schema] && param[:schema][:type])
|
|
184
|
+
# return "#{name}=#{value}" unless type&.to_sym == :array
|
|
185
|
+
return "#{name}=#{value}" unless type && type.to_sym == :array
|
|
186
|
+
|
|
187
|
+
case param[:collectionFormat]
|
|
188
|
+
when :ssv
|
|
189
|
+
"#{name}=#{value.join(' ')}"
|
|
190
|
+
when :tsv
|
|
191
|
+
"#{name}=#{value.join('\t')}"
|
|
192
|
+
when :pipes
|
|
193
|
+
"#{name}=#{value.join('|')}"
|
|
194
|
+
when :multi
|
|
195
|
+
value.map { |v| "#{name}=#{v}" }.join('&')
|
|
196
|
+
else
|
|
197
|
+
"#{name}=#{value.join(',')}" # csv is default
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def add_headers(request, metadata, swagger_doc, parameters, example)
|
|
202
|
+
tuples = parameters
|
|
203
|
+
.select { |p| p[:in] == :header }
|
|
204
|
+
.map { |p| [p[:name], example.send(extract_getter(p)).to_s] }
|
|
205
|
+
|
|
206
|
+
# Accept header
|
|
207
|
+
produces = metadata[:operation][:produces] || swagger_doc[:produces]
|
|
208
|
+
if produces
|
|
209
|
+
accept = example.respond_to?(:Accept) ? example.send(:Accept) : produces.first
|
|
210
|
+
tuples << ['Accept', accept]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Content-Type header
|
|
214
|
+
consumes = metadata[:operation][:consumes] || swagger_doc[:consumes]
|
|
215
|
+
if consumes
|
|
216
|
+
content_type = example.respond_to?(:'Content-Type') ? example.send(:'Content-Type') : consumes.first
|
|
217
|
+
tuples << ['Content-Type', content_type]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Host header
|
|
221
|
+
host = metadata[:operation][:host] || swagger_doc[:host]
|
|
222
|
+
if host.present?
|
|
223
|
+
host = example.respond_to?(:'Host') ? example.send(:'Host') : host
|
|
224
|
+
tuples << ['Host', host]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Rails test infrastructure requires rack-formatted headers
|
|
228
|
+
rack_formatted_tuples = tuples.map do |pair|
|
|
229
|
+
[
|
|
230
|
+
case pair[0]
|
|
231
|
+
when 'Accept' then 'HTTP_ACCEPT'
|
|
232
|
+
when 'Content-Type' then 'CONTENT_TYPE'
|
|
233
|
+
when 'Authorization' then 'HTTP_AUTHORIZATION'
|
|
234
|
+
when 'Host' then 'HTTP_HOST'
|
|
235
|
+
else pair[0]
|
|
236
|
+
end,
|
|
237
|
+
pair[1]
|
|
238
|
+
]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
request[:headers] = Hash[rack_formatted_tuples]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def add_payload(request, parameters, example)
|
|
245
|
+
content_type = request[:headers]['CONTENT_TYPE']
|
|
246
|
+
return if content_type.nil?
|
|
247
|
+
|
|
248
|
+
if ['application/x-www-form-urlencoded', 'multipart/form-data'].include?(content_type)
|
|
249
|
+
request[:payload] = build_form_payload(parameters, example)
|
|
250
|
+
else
|
|
251
|
+
request[:payload] = build_json_payload(parameters, example)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def build_form_payload(parameters, example)
|
|
256
|
+
# See http://seejohncode.com/2012/04/29/quick-tip-testing-multipart-uploads-with-rspec/
|
|
257
|
+
# Rather that serializing with the appropriate encoding (e.g. multipart/form-data),
|
|
258
|
+
# Rails test infrastructure allows us to send the values directly as a hash
|
|
259
|
+
# PROS: simple to implement, CONS: serialization/deserialization is bypassed in test
|
|
260
|
+
tuples = parameters
|
|
261
|
+
.select { |p| p[:in] == :formData }
|
|
262
|
+
.map { |p| [p[:name], example.send(extract_getter(p))] }
|
|
263
|
+
Hash[tuples]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def build_json_payload(parameters, example)
|
|
267
|
+
body_param = parameters.select { |p| p[:in] == :body }.first
|
|
268
|
+
|
|
269
|
+
return nil unless body_param
|
|
270
|
+
|
|
271
|
+
raise(MissingParameterError, body_param[:name]) unless example.respond_to?(body_param[:name])
|
|
272
|
+
|
|
273
|
+
example.send(body_param[:name]).to_json
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def doc_version(doc)
|
|
277
|
+
doc[:openapi] || doc[:swagger] || '3'
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def extract_getter(parameter)
|
|
281
|
+
parameter[:getter] || parameter[:name]
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
class MissingParameterError < StandardError
|
|
286
|
+
attr_reader :body_param
|
|
287
|
+
|
|
288
|
+
def initialize(body_param)
|
|
289
|
+
@body_param = body_param
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def message
|
|
293
|
+
<<-MSG
|
|
294
|
+
Missing parameter '#{body_param}'
|
|
295
|
+
|
|
296
|
+
Please check your spec. It looks like you defined a body parameter,
|
|
297
|
+
but did not declare usage via let. Try adding:
|
|
298
|
+
|
|
299
|
+
let(:#{body_param}) {}
|
|
300
|
+
MSG
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/hash/slice'
|
|
4
|
+
require 'json-schema'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'rswag/specs/extended_schema'
|
|
7
|
+
|
|
8
|
+
module Rswag
|
|
9
|
+
module Specs
|
|
10
|
+
class ResponseValidator
|
|
11
|
+
def initialize(config = ::Rswag::Specs.config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def validate!(metadata, response)
|
|
16
|
+
swagger_doc = @config.get_swagger_doc(metadata[:swagger_doc])
|
|
17
|
+
|
|
18
|
+
validate_code!(metadata, response)
|
|
19
|
+
validate_headers!(metadata, response.headers)
|
|
20
|
+
validate_body!(metadata, swagger_doc, response.body)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate_code!(metadata, response)
|
|
26
|
+
expected = metadata[:response][:code].to_s
|
|
27
|
+
if response.code != expected
|
|
28
|
+
raise UnexpectedResponse,
|
|
29
|
+
"Expected response code '#{response.code}' to match '#{expected}'\n" \
|
|
30
|
+
"Response body: #{response.body}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_headers!(metadata, headers)
|
|
35
|
+
header_schemas = (metadata[:response][:headers] || {})
|
|
36
|
+
expected = header_schemas.keys
|
|
37
|
+
expected.each do |name|
|
|
38
|
+
# nullable_attribute = header_schemas.dig(name.to_s, :schema, :nullable)
|
|
39
|
+
nullable_attribute = header_schemas[name.to_s] && header_schemas[name.to_s][:schema] && header_schemas[name.to_s][:schema][:nullable]
|
|
40
|
+
# required_attribute = header_schemas.dig(name.to_s, :required)
|
|
41
|
+
required_attribute = header_schemas[name.to_s] && header_schemas[name.to_s][:required]
|
|
42
|
+
|
|
43
|
+
is_nullable = nullable_attribute.nil? ? false : nullable_attribute
|
|
44
|
+
is_required = required_attribute.nil? ? true : required_attribute
|
|
45
|
+
|
|
46
|
+
if headers.exclude?(name.to_s) && is_required
|
|
47
|
+
raise UnexpectedResponse, "Expected response header #{name} to be present"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if headers.include?(name.to_s) && headers[name.to_s].nil? && !is_nullable
|
|
51
|
+
raise UnexpectedResponse, "Expected response header #{name} to not be null"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_body!(metadata, swagger_doc, body)
|
|
57
|
+
response_schema = metadata[:response][:schema]
|
|
58
|
+
return if response_schema.nil?
|
|
59
|
+
|
|
60
|
+
version = @config.get_swagger_doc_version(metadata[:swagger_doc])
|
|
61
|
+
schemas = definitions_or_component_schemas(swagger_doc, version)
|
|
62
|
+
|
|
63
|
+
validation_schema = response_schema
|
|
64
|
+
.merge('$schema' => 'http://tempuri.org/rswag/specs/extended_schema')
|
|
65
|
+
.merge(schemas)
|
|
66
|
+
|
|
67
|
+
validation_options = validation_options_from(metadata)
|
|
68
|
+
|
|
69
|
+
errors = JSON::Validator.fully_validate(validation_schema, body, validation_options)
|
|
70
|
+
return unless errors.any?
|
|
71
|
+
|
|
72
|
+
raise UnexpectedResponse,
|
|
73
|
+
"Expected response body to match schema: #{errors.join("\n")}\n" \
|
|
74
|
+
"Response body: #{JSON.pretty_generate(JSON.parse(body))}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validation_options_from(metadata)
|
|
78
|
+
is_strict = !!metadata.fetch(
|
|
79
|
+
:swagger_strict_schema_validation,
|
|
80
|
+
@config.swagger_strict_schema_validation
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
{ strict: is_strict }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def definitions_or_component_schemas(swagger_doc, version)
|
|
87
|
+
if version.start_with?('2')
|
|
88
|
+
swagger_doc.slice(:definitions)
|
|
89
|
+
else # Openapi3
|
|
90
|
+
if swagger_doc.key?(:definitions)
|
|
91
|
+
ActiveSupport::Deprecation.warn('Rswag::Specs: WARNING: definitions is replaced in OpenAPI3! Rename to components/schemas (in swagger_helper.rb)')
|
|
92
|
+
swagger_doc.slice(:definitions)
|
|
93
|
+
else
|
|
94
|
+
components = swagger_doc[:components] || {}
|
|
95
|
+
{ components: components }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class UnexpectedResponse < StandardError; end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/hash/deep_merge'
|
|
4
|
+
require 'rspec/core/formatters/base_text_formatter'
|
|
5
|
+
require 'swagger_helper'
|
|
6
|
+
|
|
7
|
+
module Rswag
|
|
8
|
+
module Specs
|
|
9
|
+
class SwaggerFormatter < ::RSpec::Core::Formatters::BaseTextFormatter
|
|
10
|
+
ActiveSupport::Deprecation.warn('Rswag::Specs: WARNING: Support for Ruby 2.6 will be dropped in v3.0') if RUBY_VERSION.start_with? '2.6'
|
|
11
|
+
|
|
12
|
+
if RSPEC_VERSION > 2
|
|
13
|
+
::RSpec::Core::Formatters.register self, :example_group_finished, :stop
|
|
14
|
+
else
|
|
15
|
+
ActiveSupport::Deprecation.warn('Rswag::Specs: WARNING: Support for RSpec 2.X will be dropped in v3.0')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(output, config = Rswag::Specs.config)
|
|
19
|
+
@output = output
|
|
20
|
+
@config = config
|
|
21
|
+
|
|
22
|
+
@output.puts 'Generating Swagger docs ...'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def example_group_finished(notification)
|
|
26
|
+
metadata = if RSPEC_VERSION > 2
|
|
27
|
+
notification.group.metadata
|
|
28
|
+
else
|
|
29
|
+
notification.metadata
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# !metadata[:document] won't work, since nil means we should generate
|
|
33
|
+
# docs.
|
|
34
|
+
return if metadata[:document] == false
|
|
35
|
+
return unless metadata.key?(:response)
|
|
36
|
+
|
|
37
|
+
swagger_doc = @config.get_swagger_doc(metadata[:swagger_doc])
|
|
38
|
+
|
|
39
|
+
unless doc_version(swagger_doc).start_with?('2')
|
|
40
|
+
# This is called multiple times per file!
|
|
41
|
+
# metadata[:operation] is also re-used between examples within file
|
|
42
|
+
# therefore be careful NOT to modify its content here.
|
|
43
|
+
upgrade_request_type!(metadata)
|
|
44
|
+
upgrade_servers!(swagger_doc)
|
|
45
|
+
upgrade_oauth!(swagger_doc)
|
|
46
|
+
upgrade_response_produces!(swagger_doc, metadata)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
swagger_doc.deep_merge!(metadata_to_swagger(metadata))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stop(_notification = nil)
|
|
53
|
+
@config.swagger_docs.each do |url_path, doc|
|
|
54
|
+
unless doc_version(doc).start_with?('2')
|
|
55
|
+
# doc[:paths]&.each_pair do |_k, v|
|
|
56
|
+
(doc[:paths] || {}).each_pair do |_k, v|
|
|
57
|
+
v.each_pair do |_verb, value|
|
|
58
|
+
is_hash = value.is_a?(Hash)
|
|
59
|
+
if is_hash && value[:parameters]
|
|
60
|
+
# schema_param = value[:parameters]&.find { |p| (p[:in] == :body || p[:in] == :formData) && p[:schema] }
|
|
61
|
+
schema_param = (value[:parameters] || []).find { |p| (p[:in] == :body || p[:in] == :formData) && p[:schema] }
|
|
62
|
+
mime_list = value[:consumes] || doc[:consumes]
|
|
63
|
+
|
|
64
|
+
if value && schema_param && mime_list
|
|
65
|
+
# value[:requestBody] = { content: {} } unless value.dig(:requestBody, :content)
|
|
66
|
+
value[:requestBody] = { content: {} } unless value[:requestBody] && value[:requestBody][:content]
|
|
67
|
+
value[:requestBody][:required] = true if schema_param[:required]
|
|
68
|
+
value[:requestBody][:description] = schema_param[:description] if schema_param[:description]
|
|
69
|
+
# examples = value.dig(:request_examples)
|
|
70
|
+
examples = value[:request_examples]
|
|
71
|
+
mime_list.each do |mime|
|
|
72
|
+
value[:requestBody][:content][mime] = { schema: schema_param[:schema] }
|
|
73
|
+
if examples
|
|
74
|
+
value[:requestBody][:content][mime][:examples] ||= {}
|
|
75
|
+
examples.map do |example|
|
|
76
|
+
value[:requestBody][:content][mime][:examples][example[:name]] = {
|
|
77
|
+
summary: example[:summary] || value[:summary],
|
|
78
|
+
value: example[:value]
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
value[:parameters].reject! { |p| p[:in] == :body || p[:in] == :formData }
|
|
86
|
+
end
|
|
87
|
+
remove_invalid_operation_keys!(value)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
file_path = File.join(@config.swagger_root, url_path)
|
|
93
|
+
dirname = File.dirname(file_path)
|
|
94
|
+
FileUtils.mkdir_p dirname unless File.exist?(dirname)
|
|
95
|
+
|
|
96
|
+
File.open(file_path, 'w') do |file|
|
|
97
|
+
file.write(pretty_generate(doc))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@output.puts "Swagger doc generated at #{file_path}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def pretty_generate(doc)
|
|
107
|
+
if @config.swagger_format == :yaml
|
|
108
|
+
clean_doc = yaml_prepare(doc)
|
|
109
|
+
YAML.dump(clean_doc)
|
|
110
|
+
else # config errors are thrown in 'def swagger_format', no throw needed here
|
|
111
|
+
JSON.pretty_generate(doc)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def yaml_prepare(doc)
|
|
116
|
+
json_doc = JSON.pretty_generate(doc)
|
|
117
|
+
JSON.parse(json_doc)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def metadata_to_swagger(metadata)
|
|
121
|
+
response_code = metadata[:response][:code]
|
|
122
|
+
response = metadata[:response].reject { |k, _v| k == :code }
|
|
123
|
+
|
|
124
|
+
verb = metadata[:operation][:verb]
|
|
125
|
+
operation = metadata[:operation]
|
|
126
|
+
.reject { |k, _v| k == :verb }
|
|
127
|
+
.merge(responses: { response_code => response })
|
|
128
|
+
|
|
129
|
+
path_template = metadata[:path_item][:template]
|
|
130
|
+
path_item = metadata[:path_item]
|
|
131
|
+
.reject { |k, _v| k == :template }
|
|
132
|
+
.merge(verb => operation)
|
|
133
|
+
|
|
134
|
+
{ paths: { path_template => path_item } }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def doc_version(doc)
|
|
138
|
+
doc[:openapi] || doc[:swagger] || '3'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def upgrade_response_produces!(swagger_doc, metadata)
|
|
142
|
+
# Accept header
|
|
143
|
+
mime_list = Array(metadata[:operation][:produces] || swagger_doc[:produces])
|
|
144
|
+
target_node = metadata[:response]
|
|
145
|
+
upgrade_content!(mime_list, target_node)
|
|
146
|
+
metadata[:response].delete(:schema)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def upgrade_content!(mime_list, target_node)
|
|
150
|
+
schema = target_node[:schema]
|
|
151
|
+
return if mime_list.empty? || schema.nil?
|
|
152
|
+
|
|
153
|
+
target_node[:content] ||= {}
|
|
154
|
+
mime_list.each do |mime_type|
|
|
155
|
+
# TODO: upgrade to have content-type specific schema
|
|
156
|
+
(target_node[:content][mime_type] ||= {}).merge!(schema: schema)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def upgrade_request_type!(metadata)
|
|
161
|
+
# No deprecation here as it seems valid to allow type as a shorthand
|
|
162
|
+
operation_nodes = metadata[:operation][:parameters] || []
|
|
163
|
+
path_nodes = metadata[:path_item][:parameters] || []
|
|
164
|
+
header_node = metadata[:response][:headers] || {}
|
|
165
|
+
|
|
166
|
+
(operation_nodes + path_nodes + [header_node]).each do |node|
|
|
167
|
+
if node && node[:type] && node[:schema].nil?
|
|
168
|
+
node[:schema] = { type: node[:type] }
|
|
169
|
+
node.delete(:type)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def upgrade_servers!(swagger_doc)
|
|
175
|
+
return unless swagger_doc[:servers].nil? && swagger_doc.key?(:schemes)
|
|
176
|
+
|
|
177
|
+
ActiveSupport::Deprecation.warn('Rswag::Specs: WARNING: schemes, host, and basePath are replaced in OpenAPI3! Rename to array of servers[{url}] (in swagger_helper.rb)')
|
|
178
|
+
|
|
179
|
+
swagger_doc[:servers] = { urls: [] }
|
|
180
|
+
swagger_doc[:schemes].each do |scheme|
|
|
181
|
+
swagger_doc[:servers][:urls] << scheme + '://' + swagger_doc[:host] + swagger_doc[:basePath]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
swagger_doc.delete(:schemes)
|
|
185
|
+
swagger_doc.delete(:host)
|
|
186
|
+
swagger_doc.delete(:basePath)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def upgrade_oauth!(swagger_doc)
|
|
190
|
+
# find flow in securitySchemes (securityDefinitions will have been re-written)
|
|
191
|
+
# schemes = swagger_doc.dig(:components, :securitySchemes)
|
|
192
|
+
schemes = swagger_doc[:components] && swagger_doc[:components][:securitySchemes]
|
|
193
|
+
# return unless schemes&.any? { |_k, v| v.key?(:flow) }
|
|
194
|
+
return unless schemes && schemes.any? { |_k, v| v.key?(:flow) }
|
|
195
|
+
|
|
196
|
+
schemes.each do |name, v|
|
|
197
|
+
next unless v.key?(:flow)
|
|
198
|
+
|
|
199
|
+
ActiveSupport::Deprecation.warn("Rswag::Specs: WARNING: securityDefinitions flow is replaced in OpenAPI3! Rename to components/securitySchemes/#{name}/flows[] (in swagger_helper.rb)")
|
|
200
|
+
flow = swagger_doc[:components][:securitySchemes][name].delete(:flow).to_s
|
|
201
|
+
if flow == 'accessCode'
|
|
202
|
+
ActiveSupport::Deprecation.warn("Rswag::Specs: WARNING: securityDefinitions accessCode is replaced in OpenAPI3! Rename to clientCredentials (in swagger_helper.rb)")
|
|
203
|
+
flow = 'authorizationCode'
|
|
204
|
+
end
|
|
205
|
+
if flow == 'application'
|
|
206
|
+
ActiveSupport::Deprecation.warn("Rswag::Specs: WARNING: securityDefinitions application is replaced in OpenAPI3! Rename to authorizationCode (in swagger_helper.rb)")
|
|
207
|
+
flow = 'clientCredentials'
|
|
208
|
+
end
|
|
209
|
+
flow_elements = swagger_doc[:components][:securitySchemes][name].except(:type).each_with_object({}) do |(k, _v), a|
|
|
210
|
+
a[k] = swagger_doc[:components][:securitySchemes][name].delete(k)
|
|
211
|
+
end
|
|
212
|
+
swagger_doc[:components][:securitySchemes][name].merge!(flows: { flow => flow_elements })
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def remove_invalid_operation_keys!(value)
|
|
217
|
+
return unless value.is_a?(Hash)
|
|
218
|
+
|
|
219
|
+
value.delete(:consumes) if value[:consumes]
|
|
220
|
+
value.delete(:produces) if value[:produces]
|
|
221
|
+
value.delete(:request_examples) if value[:request_examples]
|
|
222
|
+
value[:parameters].each { |p| p.delete(:getter) } if value[:parameters]
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
data/lib/rswag/specs.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/core'
|
|
4
|
+
require 'rswag/specs/example_group_helpers'
|
|
5
|
+
require 'rswag/specs/example_helpers'
|
|
6
|
+
require 'rswag/specs/configuration'
|
|
7
|
+
require 'rswag/specs/railtie' if defined?(Rails::Railtie)
|
|
8
|
+
|
|
9
|
+
module Rswag
|
|
10
|
+
module Specs
|
|
11
|
+
# Extend RSpec with a swagger-based DSL
|
|
12
|
+
::RSpec.configure do |c|
|
|
13
|
+
c.add_setting :swagger_root
|
|
14
|
+
c.add_setting :swagger_docs
|
|
15
|
+
c.add_setting :swagger_dry_run
|
|
16
|
+
c.add_setting :swagger_format
|
|
17
|
+
c.add_setting :swagger_strict_schema_validation
|
|
18
|
+
c.extend ExampleGroupHelpers, type: :request
|
|
19
|
+
c.include ExampleHelpers, type: :request
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.config
|
|
23
|
+
@config ||= Configuration.new(RSpec.configuration)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Support Rails 3+ and RSpec 2+ (sigh!)
|
|
27
|
+
RAILS_VERSION = Rails::VERSION::MAJOR
|
|
28
|
+
RSPEC_VERSION = RSpec::Core::Version::STRING.split('.').first.to_i
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/core/rake_task'
|
|
4
|
+
|
|
5
|
+
namespace :rswag do
|
|
6
|
+
namespace :specs do
|
|
7
|
+
desc 'Generate Swagger JSON files from integration specs'
|
|
8
|
+
RSpec::Core::RakeTask.new('swaggerize') do |t|
|
|
9
|
+
t.pattern = ENV.fetch(
|
|
10
|
+
'PATTERN',
|
|
11
|
+
'spec/requests/**/*_spec.rb, spec/api/**/*_spec.rb, spec/integration/**/*_spec.rb'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
additional_rspec_opts = ENV.fetch(
|
|
15
|
+
'ADDITIONAL_RSPEC_OPTS',
|
|
16
|
+
''
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
t.rspec_opts = [additional_rspec_opts]
|
|
20
|
+
|
|
21
|
+
if Rswag::Specs::RSPEC_VERSION > 2 && Rswag::Specs.config.swagger_dry_run
|
|
22
|
+
t.rspec_opts += ['--format Rswag::Specs::SwaggerFormatter', '--dry-run', '--order defined']
|
|
23
|
+
else
|
|
24
|
+
ActiveSupport::Deprecation.warn('Rswag::Specs: WARNING: Support for RSpec 2.X will be dropped in v3.0')
|
|
25
|
+
t.rspec_opts += ['--format Rswag::Specs::SwaggerFormatter', '--order defined']
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
task rswag: ['rswag:specs:swaggerize']
|