ropen_pi 0.2.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/.gitignore +13 -0
- data/.rubocop.yml +4 -0
- data/.rubocop_todo.yml +54 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +10 -0
- data/.travis.yml +7 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +177 -0
- data/LICENSE +21 -0
- data/README.md +53 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/examples/.envrc +14 -0
- data/examples/Gemfile +28 -0
- data/examples/Gemfile.lock +234 -0
- data/examples/docker-compose.yml +86 -0
- data/examples/resources/docker/docker-entrypoint.sh +15 -0
- data/lib/generators/ropen_pi/USAGE +8 -0
- data/lib/generators/ropen_pi/install_generator.rb +12 -0
- data/lib/generators/ropen_pi/templates/ropen_pi_helper.rb +25 -0
- data/lib/ropen_pi.rb +5 -0
- data/lib/ropen_pi/config_helper.rb +144 -0
- data/lib/ropen_pi/specs.rb +23 -0
- data/lib/ropen_pi/specs/configuration.rb +45 -0
- data/lib/ropen_pi/specs/example_group_helpers.rb +284 -0
- data/lib/ropen_pi/specs/example_helpers.rb +20 -0
- data/lib/ropen_pi/specs/extended_schema.rb +30 -0
- data/lib/ropen_pi/specs/open_api_formatter.rb +100 -0
- data/lib/ropen_pi/specs/railtie.rb +7 -0
- data/lib/ropen_pi/specs/request_factory.rb +178 -0
- data/lib/ropen_pi/specs/response_validator.rb +67 -0
- data/lib/ropen_pi/specs/writer.rb +35 -0
- data/lib/ropen_pi/version.rb +4 -0
- data/lib/tasks/ropen_pi.rake +10 -0
- data/ropen_pi.gemspec +36 -0
- metadata +192 -0
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ropen_pi/specs/request_factory'
|
4
|
+
require 'ropen_pi/specs/response_validator'
|
5
|
+
|
6
|
+
module RopenPi::Specs::ExampleHelpers
|
7
|
+
def submit_request(metadata)
|
8
|
+
request = RopenPi::Specs::RequestFactory.new.build_request(metadata, self)
|
9
|
+
send(
|
10
|
+
request[:verb],
|
11
|
+
request[:path],
|
12
|
+
params: request[:payload],
|
13
|
+
headers: request[:headers]
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
def assert_response_matches_metadata(metadata)
|
18
|
+
RopenPi::Specs::ResponseValidator.new.validate!(metadata, response)
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json-schema'
|
4
|
+
|
5
|
+
module RopenPi
|
6
|
+
module Specs
|
7
|
+
class ExtendedTypeAttribute < JSON::Schema::TypeV4Attribute
|
8
|
+
def self.validate(current_schema, data, fragments, processor, validator, options = {})
|
9
|
+
schema_nullable = (current_schema.schema['nullable'] == true || current_schema.schema['x-nullable'] == true)
|
10
|
+
|
11
|
+
return if data.nil? && schema_nullable
|
12
|
+
|
13
|
+
super
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class ExtendedSchema < JSON::Schema::Draft4
|
18
|
+
def initialize
|
19
|
+
super
|
20
|
+
|
21
|
+
@attributes['type'] = ExtendedTypeAttribute
|
22
|
+
temp_uri = 'http://tempuri.org/rswag/specs/extended_schema'
|
23
|
+
@uri = URI.parse(temp_uri)
|
24
|
+
@names = [temp_uri]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
JSON::Validator.register_validator(ExtendedSchema.new)
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/hash/deep_merge'
|
4
|
+
require 'ropen_pi_helper'
|
5
|
+
require 'ropen_pi/specs/writer'
|
6
|
+
|
7
|
+
class RopenPi::Specs::OpenApiFormatter
|
8
|
+
::RSpec::Core::Formatters.register self, :example_group_finished, :stop
|
9
|
+
|
10
|
+
def initialize(output, config = ::RopenPi::Specs.config)
|
11
|
+
@output = output
|
12
|
+
@config = config
|
13
|
+
|
14
|
+
@output.puts 'Generating OpenAPI docs ...'
|
15
|
+
end
|
16
|
+
|
17
|
+
def example_group_finished(notification)
|
18
|
+
metadata = notification.group.metadata
|
19
|
+
|
20
|
+
return unless metadata.key?(:response)
|
21
|
+
|
22
|
+
open_api_doc = @config.get_doc(metadata[:doc])
|
23
|
+
open_api_doc.deep_merge!(metadata_to_open_api(metadata))
|
24
|
+
end
|
25
|
+
|
26
|
+
def stop(_notification = nil)
|
27
|
+
@config.open_api_docs.each do |url_path, doc|
|
28
|
+
# remove 2.0 parameters
|
29
|
+
doc[:paths]&.each do |_k, v|
|
30
|
+
v.each do |_verb, value|
|
31
|
+
merge_clean(value)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
write(doc, url_path)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def write(doc, url_path)
|
42
|
+
file_path = File.join(@config.root_dir, url_path)
|
43
|
+
dirname = File.dirname(file_path)
|
44
|
+
FileUtils.mkdir_p(dirname) unless File.exist?(dirname)
|
45
|
+
|
46
|
+
File.open(file_path, 'w') do |file|
|
47
|
+
writer = RopenPi::Specs::Writer.new(@config.open_api_output_format)
|
48
|
+
file.write(writer.write(doc))
|
49
|
+
end
|
50
|
+
|
51
|
+
@output.puts "OpenAPI doc generated at #{file_path}"
|
52
|
+
end
|
53
|
+
|
54
|
+
def merge_clean(value)
|
55
|
+
is_hash = value.is_a?(Hash)
|
56
|
+
if is_hash && value.dig(:parameters)
|
57
|
+
schema_param = value&.dig(:parameters)&.find { |p| p[:in] == :body && p[:schema] }
|
58
|
+
|
59
|
+
value[:requestBody][:content][RopenPi::APP_JSON].merge!(schema: schema_param[:schema]) \
|
60
|
+
if value && schema_param && value&.dig(:requestBody, :content, RopenPi::APP_JSON)
|
61
|
+
|
62
|
+
value[:parameters].reject! { |p| p[:in] == :body || p[:in] == :formData }
|
63
|
+
value[:parameters].each { |p| p.delete(:type) }
|
64
|
+
value[:headers]&.each { |p| p.delete(:type) }
|
65
|
+
end
|
66
|
+
|
67
|
+
value.delete(:consumes) if is_hash && value.dig(:consumes)
|
68
|
+
value.delete(:produces) if is_hash && value.dig(:produces)
|
69
|
+
end
|
70
|
+
|
71
|
+
def metadata_to_open_api(metadata)
|
72
|
+
response_code = metadata[:response][:code]
|
73
|
+
response = metadata[:response].reject { |k, _v| k == :code }
|
74
|
+
|
75
|
+
app_json = RopenPi::APP_JSON
|
76
|
+
|
77
|
+
# need to merge in to response
|
78
|
+
if response[:examples]&.dig(app_json)
|
79
|
+
example = response[:examples].dig(app_json).dup
|
80
|
+
schema = response.dig(:content, app_json, :schema)
|
81
|
+
|
82
|
+
new_hash = { example: example }
|
83
|
+
new_hash[:schema] = schema if schema
|
84
|
+
|
85
|
+
response.merge!(content: { app_json => new_hash })
|
86
|
+
response.delete(:examples)
|
87
|
+
end
|
88
|
+
|
89
|
+
verb = metadata[:operation][:verb]
|
90
|
+
operation = metadata[:operation].reject { |k, _v| k == :verb }
|
91
|
+
.merge(responses: { response_code => response })
|
92
|
+
|
93
|
+
path_template = metadata[:path_item][:template]
|
94
|
+
|
95
|
+
path_item = metadata[:path_item].reject { |k, _v| k == :template }
|
96
|
+
.merge(verb => operation)
|
97
|
+
|
98
|
+
{ paths: { path_template => path_item } }
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/hash/slice'
|
4
|
+
require 'active_support/core_ext/hash/conversions'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
class RopenPi::Specs::RequestFactory
|
8
|
+
def initialize(config = ::RopenPi::Specs.config)
|
9
|
+
@config = config
|
10
|
+
end
|
11
|
+
|
12
|
+
def build_request(metadata, example)
|
13
|
+
open_api_doc = @config.get_doc(metadata[:doc])
|
14
|
+
parameters = expand_parameters(metadata, open_api_doc, example)
|
15
|
+
|
16
|
+
{}.tap do |request|
|
17
|
+
add_verb(request, metadata)
|
18
|
+
add_headers(request, metadata, open_api_doc, parameters, example)
|
19
|
+
add_path(request, metadata, open_api_doc, parameters, example)
|
20
|
+
add_payload(request, parameters, example)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def expand_parameters(metadata, open_api_doc, example)
|
27
|
+
operation_params = metadata[:operation][:parameters] || []
|
28
|
+
path_item_params = metadata[:path_item][:parameters] || []
|
29
|
+
security_params = derive_security_params(metadata, open_api_doc)
|
30
|
+
|
31
|
+
# NOTE: Use of + instead of concat to avoid mutation of the metadata object
|
32
|
+
(operation_params + path_item_params + security_params)
|
33
|
+
.map { |p| referenced_parameter(p, open_api_doc) }
|
34
|
+
.uniq { |p| p[:name] }
|
35
|
+
.select { |p| example.respond_to?(p[:name]) }
|
36
|
+
end
|
37
|
+
|
38
|
+
# to be able to use either '$ref' => '...' or '$ref': ... syntax
|
39
|
+
def referenced_parameter(parameter, open_api_doc)
|
40
|
+
ref_key = '$ref'
|
41
|
+
|
42
|
+
param = if parameter.key?(ref_key)
|
43
|
+
parameter.fetch(ref_key, nil)
|
44
|
+
else
|
45
|
+
parameter.fetch(ref_key.to_sym, nil)
|
46
|
+
end
|
47
|
+
|
48
|
+
return parameter if param.nil?
|
49
|
+
|
50
|
+
resolve_parameter(param, open_api_doc)
|
51
|
+
end
|
52
|
+
|
53
|
+
def derive_security_params(metadata, open_api_doc)
|
54
|
+
requirements = metadata[:operation][:security] || open_api_doc[:security] || []
|
55
|
+
scheme_names = requirements.flat_map(&:keys)
|
56
|
+
components = open_api_doc[:components] || {}
|
57
|
+
schemes = (components[:securitySchemes] || {}).slice(*scheme_names).values
|
58
|
+
|
59
|
+
schemes.map do |scheme|
|
60
|
+
param = scheme[:type] == :apiKey ? scheme.slice(:name, :in) : { name: 'Authorization', in: :header }
|
61
|
+
param.merge(type: :string, required: requirements.one?)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def resolve_parameter(ref, open_api_doc)
|
66
|
+
key = ref.sub('#/components/parameters/', '').to_sym
|
67
|
+
definitions = open_api_doc.dig(:components, :parameters)
|
68
|
+
raise "Referenced parameter '#{ref}' must be defined" unless definitions && definitions[key]
|
69
|
+
|
70
|
+
definitions[key]
|
71
|
+
end
|
72
|
+
|
73
|
+
def add_verb(request, metadata)
|
74
|
+
request[:verb] = metadata[:operation][:verb]
|
75
|
+
end
|
76
|
+
|
77
|
+
def add_path(request, metadata, open_api_doc, parameters, example)
|
78
|
+
template = (open_api_doc[:basePath] || '') + metadata[:path_item][:template]
|
79
|
+
|
80
|
+
in_path = parameters.select { |p| p[:in].to_sym == :path }
|
81
|
+
in_query = parameters.select { |p| p[:in].to_sym == :query }
|
82
|
+
|
83
|
+
request[:path] = template.tap do |inner_template|
|
84
|
+
in_path.each do |p|
|
85
|
+
inner_template.gsub!("{#{p[:name]}}", example.send(p[:name]).to_s)
|
86
|
+
end
|
87
|
+
|
88
|
+
in_query.each.with_index do |p, i|
|
89
|
+
inner_template.concat(i.zero? ? '?' : '&')
|
90
|
+
inner_template.concat(build_query_string_part(p, example.send(p[:name])))
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def build_query_string_part(param, value)
|
96
|
+
name = param[:name]
|
97
|
+
"#{name}=#{value}" # this needs refactoring for collections
|
98
|
+
|
99
|
+
# all of this is deprecated and needs reimplementation
|
100
|
+
# case param[:collectionFormat]
|
101
|
+
# when :ssv
|
102
|
+
# "#{name}=#{value.join(' ')}"
|
103
|
+
# when :tsv
|
104
|
+
# "#{name}=#{value.join('\t')}"
|
105
|
+
# when :pipes
|
106
|
+
# "#{name}=#{value.join('|')}"
|
107
|
+
# when :multi
|
108
|
+
# value.map { |v| "#{name}=#{v}" }.join('&')
|
109
|
+
# else
|
110
|
+
# "#{name}=#{value.join(',')}" # csv is default
|
111
|
+
# end
|
112
|
+
end
|
113
|
+
|
114
|
+
def add_headers(request, metadata, open_api_doc, parameters, example)
|
115
|
+
tuples = parameters.select { |p| p[:in] == :header }
|
116
|
+
.map { |p| [p[:name], example.send(p[:name]).to_s] }
|
117
|
+
# Accept header
|
118
|
+
produces = metadata[:operation][:produces] || open_api_doc[:produces]
|
119
|
+
if produces
|
120
|
+
accept = example.respond_to?(:Accept) ? example.send(:Accept) : produces.first
|
121
|
+
tuples << ['Accept', accept]
|
122
|
+
end
|
123
|
+
|
124
|
+
# Content-Type header
|
125
|
+
consumes = metadata[:operation][:consumes] || open_api_doc[:consumes]
|
126
|
+
if consumes
|
127
|
+
content_type = example.respond_to?(:'Content-Type') ? example.send(:'Content-Type') : consumes.first
|
128
|
+
tuples << ['Content-Type', content_type]
|
129
|
+
end
|
130
|
+
|
131
|
+
# Rails test infrastructure requires rackified headers
|
132
|
+
rackified_tuples = tuples.map do |pair|
|
133
|
+
[
|
134
|
+
case pair[0]
|
135
|
+
when 'Accept' then 'HTTP_ACCEPT'
|
136
|
+
when 'Content-Type' then 'CONTENT_TYPE'
|
137
|
+
when 'Authorization' then 'HTTP_AUTHORIZATION'
|
138
|
+
else pair[0]
|
139
|
+
end,
|
140
|
+
pair[1]
|
141
|
+
]
|
142
|
+
end
|
143
|
+
|
144
|
+
request[:headers] = Hash[rackified_tuples]
|
145
|
+
end
|
146
|
+
|
147
|
+
def add_payload(request, parameters, example)
|
148
|
+
content_type = request[:headers]['CONTENT_TYPE']
|
149
|
+
return if content_type.nil?
|
150
|
+
|
151
|
+
request[:payload] = if ['application/x-www-form-urlencoded', 'multipart/form-data'].include?(content_type)
|
152
|
+
build_form_payload(parameters, example)
|
153
|
+
else
|
154
|
+
build_json_payload(parameters, example)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def build_form_payload(parameters, example)
|
159
|
+
# See http://seejohncode.com/2012/04/29/quick-tip-testing-multipart-uploads-with-rspec/
|
160
|
+
# Rather that serializing with the appropriate encoding (e.g. multipart/form-data),
|
161
|
+
# Rails test infrastructure allows us to send the values directly as a hash
|
162
|
+
# PROS: simple to implement, CONS: serialization/deserialization is bypassed in test
|
163
|
+
tuples = parameters.select { |p| p[:in] == :formData }
|
164
|
+
.map { |p| [p[:name], example.send(p[:name])] }
|
165
|
+
Hash[tuples]
|
166
|
+
end
|
167
|
+
|
168
|
+
def build_json_payload(parameters, example)
|
169
|
+
body_param = parameters.select { |p| p[:in] == :body && p[:name].is_a?(Symbol) }.first
|
170
|
+
return nil unless body_param
|
171
|
+
|
172
|
+
source_body_param = example.send(body_param[:name]) \
|
173
|
+
if body_param[:name] && example.respond_to?(body_param[:name])
|
174
|
+
|
175
|
+
source_body_param ||= body_param[:param_value]
|
176
|
+
source_body_param ? source_body_param.to_json : nil
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/hash/slice'
|
4
|
+
require 'json-schema'
|
5
|
+
require 'json'
|
6
|
+
require 'ropen_pi/specs/extended_schema'
|
7
|
+
|
8
|
+
class RopenPi::Specs::ResponseValidator
|
9
|
+
def initialize(config = ::RopenPi::Specs.config)
|
10
|
+
@config = config
|
11
|
+
end
|
12
|
+
|
13
|
+
def validate!(metadata, response)
|
14
|
+
open_api_doc = @config.get_doc(metadata[:doc])
|
15
|
+
|
16
|
+
validate_code!(metadata, response)
|
17
|
+
validate_headers!(metadata, response.headers)
|
18
|
+
validate_body!(metadata, open_api_doc, response.body)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def validate_code!(metadata, response)
|
24
|
+
expected = metadata[:response][:code].to_s
|
25
|
+
|
26
|
+
# rubocop:disable Style/GuardClause
|
27
|
+
if response.code != expected
|
28
|
+
raise RopenPi::Specs::UnexpectedResponse,
|
29
|
+
"Expected response code '#{response.code}' to match '#{expected}'\n" \
|
30
|
+
"Response body: #{response.body}"
|
31
|
+
end
|
32
|
+
# rubocop:enable Style/GuardClause
|
33
|
+
end
|
34
|
+
|
35
|
+
def validate_headers!(metadata, headers)
|
36
|
+
expected = (metadata[:response][:headers] || {}).keys
|
37
|
+
expected.each do |name|
|
38
|
+
raise RopenPi::Specs::UnexpectedResponse, "Expected response header #{name} to be present" \
|
39
|
+
if headers[name.to_s].nil?
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def validate_body!(metadata, open_api_doc, body)
|
44
|
+
test_schemas = extract_schemas(metadata)
|
45
|
+
return if test_schemas.nil? || test_schemas.empty?
|
46
|
+
|
47
|
+
components = open_api_doc[:components] || {}
|
48
|
+
components_schemas = { components: components }
|
49
|
+
# response_schema
|
50
|
+
validation_schema = test_schemas[:schema].merge('$schema' => 'http://tempuri.org/rswag/specs/extended_schema')
|
51
|
+
.merge(components_schemas)
|
52
|
+
|
53
|
+
errors = JSON::Validator.fully_validate(validation_schema, body)
|
54
|
+
raise RopenPi::Specs::UnexpectedResponse, "Expected response body to match schema: #{errors[0]}" if errors.any?
|
55
|
+
end
|
56
|
+
|
57
|
+
def extract_schemas(metadata)
|
58
|
+
metadata[:operation] = { produces: [] } if metadata[:operation].nil?
|
59
|
+
produces = Array(metadata[:operation][:produces])
|
60
|
+
|
61
|
+
producer_content = produces.first || 'application/json'
|
62
|
+
response_content = metadata[:response][:content] || { producer_content => {} }
|
63
|
+
response_content[producer_content]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class RopenPi::Specs::UnexpectedResponse < StandardError; end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module RopenPi::Specs
|
2
|
+
# concrete
|
3
|
+
class Writer
|
4
|
+
# strategies
|
5
|
+
module Json
|
6
|
+
def self.convert(doc)
|
7
|
+
JSON.pretty_generate(doc)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
module Yml
|
12
|
+
require 'active_support/core_ext/hash/keys'
|
13
|
+
|
14
|
+
def self.convert(doc)
|
15
|
+
doc.deep_stringify_keys!
|
16
|
+
doc.deep_transform_values { |value| value.to_s if value.is_a?(Symbol) }
|
17
|
+
|
18
|
+
doc.to_yaml
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(open_api_output_format)
|
23
|
+
@output_format = open_api_output_format
|
24
|
+
end
|
25
|
+
|
26
|
+
def write(doc)
|
27
|
+
if @output_format == :yaml || @output_format == :yml
|
28
|
+
Yml.convert(doc)
|
29
|
+
else
|
30
|
+
# this is by any means the default
|
31
|
+
Json.convert(doc)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|