ropen_pi 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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] == :path }
81
+ in_query = parameters.select { |p| p[:in] == :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,32 @@
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.to_yaml
16
+ end
17
+ end
18
+
19
+ def initialize(open_api_output_format)
20
+ @output_format = open_api_output_format
21
+ end
22
+
23
+ def write(doc)
24
+ if @output_format == :yaml || @output_format == :yml
25
+ Yml.convert(doc)
26
+ else
27
+ # this is by any means the default
28
+ Json.convert(doc)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,4 @@
1
+ module RopenPi
2
+ VERSION = '0.1.0'.freeze
3
+ APP_JSON = 'application/json'.freeze
4
+ end