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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RopenPi::Specs::Railtie < ::Rails::Railtie
4
+ rake_tasks do
5
+ load File.expand_path('../../tasks/ropen_pi.rake', __dir__)
6
+ end
7
+ 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
@@ -0,0 +1,4 @@
1
+ module RopenPi
2
+ VERSION = '0.2.0'.freeze
3
+ APP_JSON = 'application/json'.freeze
4
+ end