miniswag 0.1.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 +7 -0
- data/Gemfile +5 -0
- data/MIT-LICENSE +21 -0
- data/README.md +255 -0
- data/lib/generators/miniswag/install/install_generator.rb +13 -0
- data/lib/generators/miniswag/install/templates/openapi_helper.rb +27 -0
- data/lib/miniswag/configuration.rb +37 -0
- data/lib/miniswag/dsl.rb +271 -0
- data/lib/miniswag/extended_schema.rb +21 -0
- data/lib/miniswag/minitest_plugin.rb +33 -0
- data/lib/miniswag/openapi_generator.rb +250 -0
- data/lib/miniswag/railtie.rb +13 -0
- data/lib/miniswag/request_factory.rb +217 -0
- data/lib/miniswag/response_validator.rb +75 -0
- data/lib/miniswag/test_case.rb +19 -0
- data/lib/miniswag/version.rb +5 -0
- data/lib/miniswag.rb +33 -0
- data/lib/tasks/miniswag_tasks.rake +27 -0
- metadata +157 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/hash/deep_merge'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
|
|
8
|
+
module Miniswag
|
|
9
|
+
# Collects metadata from test definitions and generates OpenAPI spec files.
|
|
10
|
+
# This is the Minitest equivalent of rswag's OpenapiFormatter.
|
|
11
|
+
class OpenapiGenerator
|
|
12
|
+
def initialize(config = Miniswag.config)
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def generate!
|
|
17
|
+
@config.validate!
|
|
18
|
+
|
|
19
|
+
# Process all registered test class metadata
|
|
20
|
+
Miniswag.registered_test_classes.each do |test_class|
|
|
21
|
+
test_class.miniswag_test_definitions.each do |metadata|
|
|
22
|
+
next if metadata[:document] == false
|
|
23
|
+
next unless metadata.key?(:response)
|
|
24
|
+
|
|
25
|
+
openapi_spec = @config.get_openapi_spec(metadata[:openapi_spec])
|
|
26
|
+
raise ConfigurationError, 'Unsupported OpenAPI version' unless doc_version(openapi_spec)&.start_with?('3')
|
|
27
|
+
|
|
28
|
+
upgrade_request_type!(metadata)
|
|
29
|
+
upgrade_response_produces!(openapi_spec, metadata)
|
|
30
|
+
openapi_spec.deep_merge!(metadata_to_openapi(metadata))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Post-process and write files
|
|
35
|
+
@config.openapi_specs.each do |url_path, doc|
|
|
36
|
+
parse_parameters(doc)
|
|
37
|
+
file_path = File.join(@config.openapi_root, url_path)
|
|
38
|
+
dirname = File.dirname(file_path)
|
|
39
|
+
FileUtils.mkdir_p(dirname) unless File.exist?(dirname)
|
|
40
|
+
File.open(file_path, 'w') do |file|
|
|
41
|
+
file.write(pretty_generate(doc))
|
|
42
|
+
end
|
|
43
|
+
puts "Miniswag: OpenAPI doc generated at #{file_path}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def doc_version(doc)
|
|
50
|
+
doc[:openapi]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def pretty_generate(doc)
|
|
54
|
+
if @config.openapi_format == :yaml
|
|
55
|
+
clean_doc = JSON.parse(JSON.pretty_generate(doc))
|
|
56
|
+
YAML.dump(clean_doc)
|
|
57
|
+
else
|
|
58
|
+
JSON.pretty_generate(doc)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def metadata_to_openapi(metadata)
|
|
63
|
+
response_code = metadata[:response][:code]
|
|
64
|
+
response = metadata[:response].reject { |k, _v| k == :code }
|
|
65
|
+
verb = metadata[:operation][:verb]
|
|
66
|
+
operation = metadata[:operation]
|
|
67
|
+
.reject { |k, _v| k == :verb }
|
|
68
|
+
.merge(responses: { response_code => response })
|
|
69
|
+
path_template = metadata[:path_item][:template]
|
|
70
|
+
path_item = metadata[:path_item]
|
|
71
|
+
.reject { |k, _v| k == :template }
|
|
72
|
+
.merge(verb => operation)
|
|
73
|
+
{ paths: { path_template => path_item } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def upgrade_response_produces!(openapi_spec, metadata)
|
|
77
|
+
mime_list = Array(metadata[:operation][:produces] || openapi_spec[:produces])
|
|
78
|
+
target_node = metadata[:response]
|
|
79
|
+
upgrade_content!(mime_list, target_node)
|
|
80
|
+
metadata[:response].delete(:schema)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def upgrade_content!(mime_list, target_node)
|
|
84
|
+
schema = target_node[:schema]
|
|
85
|
+
return if mime_list.empty? || schema.nil?
|
|
86
|
+
|
|
87
|
+
target_node[:content] ||= {}
|
|
88
|
+
mime_list.each do |mime_type|
|
|
89
|
+
(target_node[:content][mime_type] ||= {}).merge!(schema: schema)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def upgrade_request_type!(metadata)
|
|
94
|
+
operation_nodes = metadata[:operation][:parameters] || []
|
|
95
|
+
path_nodes = metadata[:path_item][:parameters] || []
|
|
96
|
+
header_node = metadata[:response][:headers] || {}
|
|
97
|
+
(operation_nodes + path_nodes + header_node.values).each do |node|
|
|
98
|
+
if node && node[:type] && node[:schema].nil?
|
|
99
|
+
node[:schema] = { type: node[:type] }
|
|
100
|
+
node.delete(:type)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def remove_invalid_operation_keys!(value)
|
|
106
|
+
return unless value.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
value.delete(:consumes) if value[:consumes]
|
|
109
|
+
value.delete(:produces) if value[:produces]
|
|
110
|
+
value.delete(:request_examples) if value[:request_examples]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parse_parameters(doc)
|
|
114
|
+
doc[:paths]&.each_pair do |_k, path|
|
|
115
|
+
path.each_pair do |_verb, endpoint|
|
|
116
|
+
is_hash = endpoint.is_a?(Hash)
|
|
117
|
+
if is_hash && endpoint[:parameters]
|
|
118
|
+
mime_list = endpoint[:consumes] || doc[:consumes]
|
|
119
|
+
parse_endpoint(endpoint, mime_list)
|
|
120
|
+
end
|
|
121
|
+
remove_invalid_operation_keys!(endpoint)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_endpoint(endpoint, mime_list)
|
|
127
|
+
parameters = endpoint[:parameters]
|
|
128
|
+
parameters.each do |parameter|
|
|
129
|
+
set_parameter_schema(parameter)
|
|
130
|
+
convert_file_parameter(parameter)
|
|
131
|
+
parse_enum(parameter)
|
|
132
|
+
end
|
|
133
|
+
parameters.select { |p| parameter_in_form_data_or_body?(p) }.each do |parameter|
|
|
134
|
+
parse_form_data_or_body_parameter(endpoint, parameter, mime_list)
|
|
135
|
+
parameters.delete(parameter)
|
|
136
|
+
end
|
|
137
|
+
parameters.each { |p| p.delete(:schema) if p[:schema].blank? }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def set_parameter_schema(parameter)
|
|
141
|
+
parameter[:schema] ||= {}
|
|
142
|
+
if parameter[:schema].key?(:required) && parameter[:schema][:required] == true
|
|
143
|
+
parameter[:required] = parameter[:schema].delete(:required)
|
|
144
|
+
end
|
|
145
|
+
parameter[:schema][:type] = parameter.delete(:type) if parameter.key?(:type)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parameter_in_form_data_or_body?(p)
|
|
149
|
+
p[:in] == :formData || parameter_in_body?(p)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def parameter_in_body?(p)
|
|
153
|
+
p[:in] == :body
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def parse_form_data_or_body_parameter(endpoint, parameter, mime_list)
|
|
157
|
+
unless mime_list
|
|
158
|
+
raise ConfigurationError,
|
|
159
|
+
'A body or form data parameters are specified without a Media Type for the content'
|
|
160
|
+
end
|
|
161
|
+
add_request_body(endpoint)
|
|
162
|
+
desc = parameter.delete(:description)
|
|
163
|
+
parameter[:schema][:description] = desc if desc
|
|
164
|
+
mime_list.each do |mime|
|
|
165
|
+
endpoint[:requestBody][:content][mime] ||= {}
|
|
166
|
+
mime_config = endpoint[:requestBody][:content][mime]
|
|
167
|
+
next unless mime_config[:schema].nil? || mime_config.dig(:schema, :properties)
|
|
168
|
+
|
|
169
|
+
set_mime_config(mime_config, parameter)
|
|
170
|
+
set_mime_examples(mime_config, endpoint)
|
|
171
|
+
set_request_body_required(mime_config, endpoint, parameter)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def add_request_body(endpoint)
|
|
176
|
+
return if endpoint.dig(:requestBody, :content)
|
|
177
|
+
|
|
178
|
+
endpoint[:requestBody] = { content: {} }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def set_request_body_required(mime_config, endpoint, parameter)
|
|
182
|
+
return unless parameter[:required]
|
|
183
|
+
|
|
184
|
+
endpoint[:requestBody][:required] = true
|
|
185
|
+
return if parameter_in_body?(parameter)
|
|
186
|
+
|
|
187
|
+
if parameter[:name]
|
|
188
|
+
mime_config[:schema][:required] ||= []
|
|
189
|
+
mime_config[:schema][:required] << parameter[:name].to_s
|
|
190
|
+
else
|
|
191
|
+
mime_config[:schema][:required] = true
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def convert_file_parameter(parameter)
|
|
196
|
+
return unless parameter[:schema][:type] == :file
|
|
197
|
+
|
|
198
|
+
parameter[:schema][:type] = :string
|
|
199
|
+
parameter[:schema][:format] = :binary
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def set_mime_config(mime_config, parameter)
|
|
203
|
+
schema_with_form_properties = parameter[:name] && !parameter_in_body?(parameter)
|
|
204
|
+
mime_config[:schema] ||= schema_with_form_properties ? { type: :object, properties: {} } : parameter[:schema]
|
|
205
|
+
return unless schema_with_form_properties
|
|
206
|
+
|
|
207
|
+
mime_config[:schema][:properties][parameter[:name]] = parameter[:schema]
|
|
208
|
+
set_mime_encoding(mime_config, parameter)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def set_mime_encoding(mime_config, parameter)
|
|
212
|
+
return unless parameter[:encoding]
|
|
213
|
+
|
|
214
|
+
encoding = parameter[:encoding].dup
|
|
215
|
+
encoding[:contentType] = encoding[:contentType].join(',') if encoding[:contentType].is_a?(Array)
|
|
216
|
+
mime_config[:encoding] ||= {}
|
|
217
|
+
mime_config[:encoding][parameter[:name]] = encoding
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def set_mime_examples(mime_config, endpoint)
|
|
221
|
+
examples = endpoint[:request_examples]
|
|
222
|
+
return unless examples
|
|
223
|
+
|
|
224
|
+
examples.each do |ex|
|
|
225
|
+
mime_config[:examples] ||= {}
|
|
226
|
+
mime_config[:examples][ex[:name]] = {
|
|
227
|
+
summary: ex[:summary] || endpoint[:summary],
|
|
228
|
+
value: ex[:value]
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def parse_enum(parameter)
|
|
234
|
+
return unless parameter.key?(:enum)
|
|
235
|
+
|
|
236
|
+
enum = parameter.delete(:enum)
|
|
237
|
+
parameter[:schema] ||= {}
|
|
238
|
+
parameter[:schema][:enum] = enum.is_a?(Hash) ? enum.keys.map(&:to_s) : enum
|
|
239
|
+
parameter[:description] = generate_enum_description(parameter, enum) if enum.is_a?(Hash)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def generate_enum_description(param, enum)
|
|
243
|
+
enum_description = "#{param[:description]}:\n "
|
|
244
|
+
enum.each do |k, v|
|
|
245
|
+
enum_description += "* `#{k}` #{v}\n "
|
|
246
|
+
end
|
|
247
|
+
enum_description
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Miniswag
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
rake_tasks do
|
|
6
|
+
load File.expand_path('../tasks/miniswag_tasks.rake', __dir__)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
generators do
|
|
10
|
+
require 'generators/miniswag/install/install_generator'
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/core_ext/hash/slice'
|
|
5
|
+
require 'active_support/core_ext/hash/conversions'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'cgi'
|
|
8
|
+
|
|
9
|
+
module Miniswag
|
|
10
|
+
class RequestFactory
|
|
11
|
+
attr_accessor :metadata, :params, :headers
|
|
12
|
+
|
|
13
|
+
def initialize(metadata, params = {}, headers = {}, config = Miniswag.config)
|
|
14
|
+
@config = config
|
|
15
|
+
@metadata = metadata
|
|
16
|
+
@params = params.transform_keys(&:to_s)
|
|
17
|
+
@headers = headers.transform_keys(&:to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build_request
|
|
21
|
+
openapi_spec = @config.get_openapi_spec(metadata[:openapi_spec])
|
|
22
|
+
parameters = expand_parameters(metadata, openapi_spec)
|
|
23
|
+
{}.tap do |request|
|
|
24
|
+
add_verb(request, metadata)
|
|
25
|
+
add_path(request, metadata, openapi_spec, parameters)
|
|
26
|
+
add_headers(request, metadata, openapi_spec, parameters)
|
|
27
|
+
add_payload(request, parameters)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def expand_parameters(metadata, openapi_spec)
|
|
34
|
+
operation_params = metadata[:operation][:parameters] || []
|
|
35
|
+
path_item_params = metadata[:path_item][:parameters] || []
|
|
36
|
+
security_params = derive_security_params(metadata, openapi_spec)
|
|
37
|
+
(operation_params + path_item_params + security_params)
|
|
38
|
+
.map { |p| p['$ref'] ? resolve_parameter(p['$ref'], openapi_spec) : p }
|
|
39
|
+
.uniq { |p| p[:name] }
|
|
40
|
+
.reject do |p|
|
|
41
|
+
p[:required] == false &&
|
|
42
|
+
!@headers.key?(p[:name].to_s) &&
|
|
43
|
+
!@params.key?(p[:name].to_s)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def derive_security_params(metadata, openapi_spec)
|
|
48
|
+
requirements = metadata[:operation][:security] || openapi_spec[:security] || []
|
|
49
|
+
scheme_names = requirements.flat_map(&:keys)
|
|
50
|
+
schemes = security_version(scheme_names, openapi_spec)
|
|
51
|
+
schemes.map do |scheme|
|
|
52
|
+
param = scheme[:type] == :apiKey ? scheme.slice(:name, :in) : { name: 'Authorization', in: :header }
|
|
53
|
+
param.merge(schema: { type: :string }, required: requirements.one?)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def security_version(scheme_names, openapi_spec)
|
|
58
|
+
components = openapi_spec[:components] || {}
|
|
59
|
+
(components[:securitySchemes] || {}).slice(*scheme_names).values
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resolve_parameter(ref, openapi_spec)
|
|
63
|
+
key = ref.sub('#/components/parameters/', '').to_sym
|
|
64
|
+
definitions = (openapi_spec[:components] || {})[:parameters]
|
|
65
|
+
raise "Referenced parameter '#{ref}' must be defined" unless definitions && definitions[key]
|
|
66
|
+
|
|
67
|
+
definitions[key]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_verb(request, metadata)
|
|
71
|
+
request[:verb] = metadata[:operation][:verb]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def base_path_from_servers(openapi_spec, use_server = :default)
|
|
75
|
+
return '' if openapi_spec[:servers].nil? || openapi_spec[:servers].empty?
|
|
76
|
+
|
|
77
|
+
server = openapi_spec[:servers].first
|
|
78
|
+
variables = {}
|
|
79
|
+
server.fetch(:variables, {}).each_pair { |k, v| variables[k] = v[use_server] }
|
|
80
|
+
base_path = server[:url].gsub(/\{(.*?)\}/) { variables[::Regexp.last_match(1).to_sym] }
|
|
81
|
+
URI(base_path).path
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def add_path(request, metadata, openapi_spec, parameters)
|
|
85
|
+
template = base_path_from_servers(openapi_spec) + metadata[:path_item][:template]
|
|
86
|
+
request[:path] = template.tap do |path_template|
|
|
87
|
+
parameters.select { |p| p[:in] == :path }.each do |p|
|
|
88
|
+
param_value = @params.fetch(p[:name].to_s) do
|
|
89
|
+
raise ArgumentError, "`#{p[:name]}` parameter key present in path but not provided in params"
|
|
90
|
+
end
|
|
91
|
+
path_template.gsub!("{#{p[:name]}}", param_value.to_s)
|
|
92
|
+
end
|
|
93
|
+
parameters.select { |p| p[:in] == :query && @params.key?(p[:name].to_s) }.each_with_index do |p, i|
|
|
94
|
+
path_template.concat(i.zero? ? '?' : '&')
|
|
95
|
+
path_template.concat(build_query_string_part(p, @params.fetch(p[:name].to_s), openapi_spec))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_query_string_part(param, value, _openapi_spec)
|
|
101
|
+
name = param[:name]
|
|
102
|
+
escaped_name = CGI.escape(name.to_s)
|
|
103
|
+
return unless param[:schema]
|
|
104
|
+
|
|
105
|
+
style = param[:style]&.to_sym || :form
|
|
106
|
+
explode = param[:explode].nil? || param[:explode]
|
|
107
|
+
type = param.dig(:schema, :type)&.to_sym
|
|
108
|
+
case type
|
|
109
|
+
when :object
|
|
110
|
+
case style
|
|
111
|
+
when :deepObject then { name => value }.to_query
|
|
112
|
+
when :form
|
|
113
|
+
return value.to_query if explode
|
|
114
|
+
|
|
115
|
+
"#{escaped_name}=" + value.to_a.flatten.map { |v| CGI.escape(v.to_s) }.join(',')
|
|
116
|
+
end
|
|
117
|
+
when :array
|
|
118
|
+
case explode
|
|
119
|
+
when true
|
|
120
|
+
value.to_a.flatten.map { |v| "#{escaped_name}=#{CGI.escape(v.to_s)}" }.join('&')
|
|
121
|
+
else
|
|
122
|
+
separator = case style
|
|
123
|
+
when :form then ','
|
|
124
|
+
when :spaceDelimited then '%20'
|
|
125
|
+
when :pipeDelimited then '|'
|
|
126
|
+
end
|
|
127
|
+
"#{escaped_name}=" + value.to_a.flatten.map { |v| CGI.escape(v.to_s) }.join(separator)
|
|
128
|
+
end
|
|
129
|
+
else
|
|
130
|
+
"#{escaped_name}=#{CGI.escape(value.to_s)}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def add_headers(request, metadata, openapi_spec, parameters)
|
|
135
|
+
tuples = parameters
|
|
136
|
+
.select { |p| p[:in] == :header }
|
|
137
|
+
.map { |p| [p[:name], @headers.fetch(p[:name].to_s, '').to_s] }
|
|
138
|
+
|
|
139
|
+
produces = metadata[:operation][:produces] || openapi_spec[:produces]
|
|
140
|
+
if produces
|
|
141
|
+
accept = @headers.fetch('Accept', produces.first)
|
|
142
|
+
tuples << ['Accept', accept]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
consumes = metadata[:operation][:consumes] || openapi_spec[:consumes]
|
|
146
|
+
if consumes
|
|
147
|
+
content_type = @headers.fetch('Content-Type', consumes.first)
|
|
148
|
+
tuples << ['Content-Type', content_type]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
host = metadata[:operation][:host] || openapi_spec[:host]
|
|
152
|
+
tuples << ['Host', host] if host.present?
|
|
153
|
+
|
|
154
|
+
rack_formatted_tuples = tuples.map do |pair|
|
|
155
|
+
[
|
|
156
|
+
case pair[0]
|
|
157
|
+
when 'Accept' then 'HTTP_ACCEPT'
|
|
158
|
+
when 'Content-Type' then 'CONTENT_TYPE'
|
|
159
|
+
when 'Authorization' then 'HTTP_AUTHORIZATION'
|
|
160
|
+
when 'Host' then 'HTTP_HOST'
|
|
161
|
+
else pair[0]
|
|
162
|
+
end,
|
|
163
|
+
pair[1]
|
|
164
|
+
]
|
|
165
|
+
end
|
|
166
|
+
request[:headers] = Hash[rack_formatted_tuples]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def add_payload(request, parameters)
|
|
170
|
+
content_type = request[:headers]['CONTENT_TYPE']
|
|
171
|
+
return if content_type.nil?
|
|
172
|
+
|
|
173
|
+
request[:payload] = if ['application/x-www-form-urlencoded', 'multipart/form-data'].include?(content_type)
|
|
174
|
+
build_form_payload(parameters)
|
|
175
|
+
elsif %r{\Aapplication/([0-9A-Za-z._-]+\+json\z|json\z)}.match?(content_type)
|
|
176
|
+
build_json_payload(parameters)
|
|
177
|
+
else
|
|
178
|
+
build_raw_payload(parameters)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build_form_payload(parameters)
|
|
183
|
+
tuples = parameters
|
|
184
|
+
.select { |p| p[:in] == :formData }
|
|
185
|
+
.map { |p| [p[:name], @params.fetch(p[:name].to_s)] }
|
|
186
|
+
Hash[tuples]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_raw_payload(parameters)
|
|
190
|
+
body_param = parameters.find { |p| p[:in] == :body }
|
|
191
|
+
return nil unless body_param
|
|
192
|
+
|
|
193
|
+
@params.fetch(body_param[:name].to_s) do
|
|
194
|
+
raise MissingParameterError, body_param[:name]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_json_payload(parameters)
|
|
199
|
+
build_raw_payload(parameters)&.to_json
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
class MissingParameterError < StandardError
|
|
204
|
+
attr_reader :body_param
|
|
205
|
+
|
|
206
|
+
def initialize(body_param)
|
|
207
|
+
@body_param = body_param
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def message
|
|
211
|
+
<<~MSG
|
|
212
|
+
Missing parameter '#{body_param}'
|
|
213
|
+
Please check your test. Ensure you provide the parameter in a `params` block.
|
|
214
|
+
MSG
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/enumerable'
|
|
4
|
+
require 'active_support/core_ext/hash/slice'
|
|
5
|
+
require 'json-schema'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'miniswag/extended_schema'
|
|
8
|
+
|
|
9
|
+
module Miniswag
|
|
10
|
+
class ResponseValidator
|
|
11
|
+
def initialize(config = Miniswag.config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def validate!(metadata, response)
|
|
16
|
+
openapi_spec = @config.get_openapi_spec(metadata[:openapi_spec])
|
|
17
|
+
validate_code!(metadata, response)
|
|
18
|
+
validate_headers!(metadata, response.headers)
|
|
19
|
+
validate_body!(metadata, openapi_spec, response.body)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def validate_code!(metadata, response)
|
|
25
|
+
expected = metadata[:response][:code].to_s
|
|
26
|
+
return unless response.code != expected
|
|
27
|
+
|
|
28
|
+
raise UnexpectedResponse,
|
|
29
|
+
"Expected response code '#{response.code}' to match '#{expected}'\n" \
|
|
30
|
+
"Response body: #{response.body}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_headers!(metadata, headers)
|
|
34
|
+
header_schemas = metadata[:response][:headers] || {}
|
|
35
|
+
header_schemas.keys.each do |name|
|
|
36
|
+
nullable_attribute = header_schemas.dig(name.to_s, :schema, :nullable)
|
|
37
|
+
required_attribute = header_schemas.dig(name.to_s, :required)
|
|
38
|
+
is_nullable = nullable_attribute.nil? ? false : nullable_attribute
|
|
39
|
+
is_required = required_attribute.nil? || required_attribute
|
|
40
|
+
|
|
41
|
+
if !headers.include?(name.to_s) && is_required
|
|
42
|
+
raise UnexpectedResponse, "Expected response header #{name} to be present"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if headers.include?(name.to_s) && headers[name.to_s].nil? && !is_nullable
|
|
46
|
+
raise UnexpectedResponse, "Expected response header #{name} to not be null"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_body!(metadata, openapi_spec, body)
|
|
52
|
+
response_schema = metadata[:response][:schema]
|
|
53
|
+
return if response_schema.nil?
|
|
54
|
+
|
|
55
|
+
schemas = { components: openapi_spec[:components] }
|
|
56
|
+
validation_schema = response_schema
|
|
57
|
+
.merge('$schema' => 'http://tempuri.org/miniswag/extended_schema')
|
|
58
|
+
.merge(schemas)
|
|
59
|
+
validation_options = {
|
|
60
|
+
allPropertiesRequired: metadata.fetch(:openapi_all_properties_required,
|
|
61
|
+
@config.openapi_all_properties_required),
|
|
62
|
+
noAdditionalProperties: metadata.fetch(:openapi_no_additional_properties,
|
|
63
|
+
@config.openapi_no_additional_properties)
|
|
64
|
+
}
|
|
65
|
+
errors = JSON::Validator.fully_validate(validation_schema, body, validation_options)
|
|
66
|
+
return unless errors.any?
|
|
67
|
+
|
|
68
|
+
raise UnexpectedResponse,
|
|
69
|
+
"Expected response body to match schema: #{errors.join("\n")}\n" \
|
|
70
|
+
"Response body: #{JSON.pretty_generate(JSON.parse(body))}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class UnexpectedResponse < StandardError; end
|
|
75
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_dispatch/testing/integration'
|
|
4
|
+
require 'miniswag/dsl'
|
|
5
|
+
require 'miniswag/request_factory'
|
|
6
|
+
require 'miniswag/response_validator'
|
|
7
|
+
|
|
8
|
+
module Miniswag
|
|
9
|
+
class TestCase < ActionDispatch::IntegrationTest
|
|
10
|
+
extend DSL
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Returns all test definitions registered via run_test! for OpenAPI generation
|
|
14
|
+
def miniswag_test_definitions
|
|
15
|
+
@_miniswag_test_definitions || []
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/miniswag.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'miniswag/version'
|
|
4
|
+
require 'miniswag/configuration'
|
|
5
|
+
require 'miniswag/railtie' if defined?(Rails::Railtie)
|
|
6
|
+
|
|
7
|
+
module Miniswag
|
|
8
|
+
class << self
|
|
9
|
+
def configure
|
|
10
|
+
yield(config)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def config
|
|
14
|
+
@config ||= Configuration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Reset configuration (useful for testing)
|
|
18
|
+
def reset!
|
|
19
|
+
@config = nil
|
|
20
|
+
@registered_test_classes = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Registry of test classes that have miniswag test definitions.
|
|
24
|
+
# Used by OpenapiGenerator to collect all metadata.
|
|
25
|
+
def register_test_class(klass)
|
|
26
|
+
registered_test_classes << klass unless registered_test_classes.include?(klass)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def registered_test_classes
|
|
30
|
+
@registered_test_classes ||= []
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :miniswag do
|
|
4
|
+
desc 'Generate OpenAPI spec files from Minitest integration tests'
|
|
5
|
+
task swaggerize: :environment do
|
|
6
|
+
pattern = ENV.fetch('PATTERN', 'test/integration/**/*_test.rb')
|
|
7
|
+
additional_opts = ENV.fetch('ADDITIONAL_OPTS', '')
|
|
8
|
+
|
|
9
|
+
# Set the env var so the minitest plugin activates
|
|
10
|
+
ENV['MINISWAG_GENERATE'] = '1'
|
|
11
|
+
|
|
12
|
+
# Require the openapi_helper which configures Miniswag
|
|
13
|
+
helper_path = Rails.root.join('test', 'openapi_helper.rb')
|
|
14
|
+
require helper_path.to_s if File.exist?(helper_path)
|
|
15
|
+
|
|
16
|
+
# Run minitest with the matching pattern
|
|
17
|
+
args = [
|
|
18
|
+
'bin/rails', 'test',
|
|
19
|
+
*Dir.glob(pattern),
|
|
20
|
+
additional_opts.presence
|
|
21
|
+
].compact
|
|
22
|
+
|
|
23
|
+
system(*args) || abort('Miniswag: Test run failed')
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
task miniswag: ['miniswag:swaggerize']
|