ropen_pi 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- 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 +11 -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 +134 -0
- data/lib/ropen_pi/specs.rb +23 -0
- data/lib/ropen_pi/specs/configuration.rb +41 -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
|