rspec-rails-api 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/hash_with_indifferent_access'
4
+
5
+ require 'rspec/rails/api/utils'
6
+
7
+ # FIXME: Split the matcher in something else; it's too messy.
8
+ RSpec::Matchers.define :have_many do |expected|
9
+ match do |actual|
10
+ @actual = actual
11
+ @actual = JSON.parse(actual.body) if actual.respond_to? :body
12
+
13
+ raise "Response is not an array: #{@actual.class}" unless @actual.is_a? Array
14
+ raise 'Response has no item to compare with' unless @actual.count.positive?
15
+
16
+ # Check every entry
17
+ ok = true
18
+ @actual.each do |item|
19
+ ok = false unless RSpec::Rails::Api::Utils.validate_object_structure item, expected
20
+ end
21
+
22
+ ok
23
+ end
24
+
25
+ diffable
26
+ end
27
+
28
+ # FIXME: Split the matcher in something else; it's too messy.
29
+ RSpec::Matchers.define :have_one do |expected|
30
+ match do |actual|
31
+ @actual = actual
32
+ @actual = JSON.parse(actual.body) if actual.respond_to? :body
33
+
34
+ raise "Response is not a hash: #{@actual.class}" unless @actual.is_a? Hash
35
+
36
+ RSpec::Rails::Api::Utils.validate_object_structure @actual, expected
37
+ end
38
+
39
+ diffable
40
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/rails/api/utils'
4
+ require 'rspec/rails/api/open_api_renderer'
5
+ require 'rspec/rails/api/entity_config'
6
+
7
+ module RSpec
8
+ module Rails
9
+ module Api
10
+ # Handles contexts and examples metadatas.
11
+ class Metadata # rubocop:disable Metrics/ClassLength
12
+ attr_reader :entities, :resources, :current_resource
13
+
14
+ def initialize
15
+ @resources = {}
16
+ @entities = {}
17
+ # Only used when building metadata during RSpec boot
18
+ @current_resource = nil
19
+ @current_method = nil
20
+ @current_url = nil
21
+ @current_code = nil
22
+ end
23
+
24
+ def add_resource(name, description)
25
+ @resources[name.to_sym] = { description: description, paths: {} }
26
+ @current_resource = name.to_sym
27
+ end
28
+
29
+ def add_entity(type, fields)
30
+ Utils.deep_set(@resources,
31
+ "#{@current_resource}.entities.#{type}",
32
+ EntityConfig.new(fields))
33
+ end
34
+
35
+ def add_path_params(fields) # rubocop:disable Metrics/MethodLength
36
+ check_current_context :resource, :url
37
+
38
+ chunks = @current_url.split('?')
39
+
40
+ fields.each do |name, field|
41
+ valid_attribute = Utils.check_attribute_type(field[:type], except: %i[array object])
42
+ raise "Field type not allowed: #{field[:type]}" unless valid_attribute
43
+
44
+ scope = path_param_scope(chunks, name)
45
+ Utils.deep_set(@resources, "#{@current_resource}.paths.#{@current_url}.path_params.#{name}",
46
+ description: field[:description] || nil,
47
+ type: field[:type] || nil,
48
+ required: field[:required] || true,
49
+ scope: scope)
50
+ end
51
+ end
52
+
53
+ # Fields should be something like:
54
+ # id: {type: :number, description: 'Something'},
55
+ # name: {type: string, description: 'Something'}
56
+ # Ex. with sub elements:
57
+ # id: {type: :number, description: 'Something'},
58
+ # something: {type: :object, description: 'Something', properties: {
59
+ # property: {type: :string, description: 'Something'},
60
+ # ...
61
+ # }}
62
+ def add_request_params(fields)
63
+ check_current_context :resource, :url, :method
64
+
65
+ params = organize_params fields
66
+ Utils.deep_set(@resources,
67
+ "#{@current_resource}.paths.#{@current_url}.actions.#{@current_method}.params",
68
+ params)
69
+ end
70
+
71
+ def add_action(method, url, description)
72
+ check_current_context :resource
73
+
74
+ Utils.deep_set(@resources, "#{@current_resource}.paths.#{url}.actions.#{method}",
75
+ description: description,
76
+ statuses: {},
77
+ params: [])
78
+
79
+ @current_url = url
80
+ @current_method = method
81
+ end
82
+
83
+ # rubocop:disable Metrics/LineLength
84
+ def add_status_code(status_code, description)
85
+ check_current_context :resource, :url, :method
86
+
87
+ Utils.deep_set(@resources,
88
+ "#{@current_resource}.paths.#{@current_url}.actions.#{@current_method}.statuses.#{status_code}",
89
+ description: description,
90
+ example: { response: nil })
91
+ @current_code = status_code
92
+ end
93
+ # rubocop:enable Metrics/LineLength
94
+
95
+ # rubocop:disable Metrics/ParameterLists
96
+ def add_request_example(url: nil, action: nil, status_code: nil, response: nil, path_params: nil, params: nil)
97
+ resource = nil
98
+ @resources.each do |key, res|
99
+ resource = key if Utils.deep_get(res, "paths.#{url}.actions.#{action}.statuses.#{status_code}")
100
+ end
101
+
102
+ raise "Resource not found for #{action.upcase} #{url}" unless resource
103
+
104
+ Utils.deep_set(@resources,
105
+ "#{resource}.paths.#{url}.actions.#{action}.statuses.#{status_code}.example",
106
+ path_params: path_params,
107
+ params: params,
108
+ response: response)
109
+ end
110
+ # rubocop:enable Metrics/ParameterLists
111
+
112
+ def to_h
113
+ {
114
+ resources: @resources,
115
+ entities: @entities,
116
+ }
117
+ end
118
+
119
+ private
120
+
121
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Style/GuardClause
122
+ def check_current_context(*scope)
123
+ scope ||= []
124
+ if scope.include?(:resource)
125
+ raise 'No resource declared' unless @current_resource
126
+ end
127
+ if scope.include?(:method)
128
+ raise 'No action declared' unless @current_method
129
+ end
130
+ if scope.include?(:url)
131
+ raise 'No url declared' unless @current_url
132
+ end
133
+ if scope.include?(:code)
134
+ raise 'No status code declared' unless @current_code
135
+ end
136
+ end
137
+
138
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Style/GuardClause
139
+
140
+ def path_param_scope(url_chunks, name)
141
+ if /:#{name}/ =~ url_chunks[0]
142
+ :path
143
+ elsif url_chunks[1] && /:#{name}/ =~ url_chunks[1]
144
+ :query
145
+ else
146
+ raise "#{name} not found in URL #{@current_url}"
147
+ end
148
+ end
149
+
150
+ def organize_params(fields) # rubocop:disable Metrics/AbcSize
151
+ out = { properties: {} }
152
+ required = []
153
+ fields.each do |name, field|
154
+ allowed_type = %i[array object].include?(field[:type]) || PARAM_TYPES.key?(field[:type])
155
+ raise "Field type not allowed: #{field[:type]}" unless allowed_type
156
+
157
+ required.push name.to_s if field[:required]
158
+
159
+ out[:properties][name] = fill_request_param field
160
+ end
161
+ out[:required] = required if required.count.positive?
162
+ out
163
+ end
164
+
165
+ def fill_request_param(field)
166
+ if field[:type] && field[:type] == :object
167
+ organize_params field[:properties] if field[:properties]
168
+ else
169
+ {
170
+ type: field[:type].to_s,
171
+ description: field[:description] || nil,
172
+ }
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/rails/api/utils'
4
+ require 'active_support'
5
+
6
+ module RSpec
7
+ module Rails
8
+ module Api
9
+ # Class to render metadatas.
10
+ # Example:
11
+ # ```rb
12
+ # renderer = RSpec::Rails::Api::OpenApiRenderer.new
13
+ # renderer.merge_context(example_context)
14
+ # renderer.write_files
15
+ # ```
16
+ class OpenApiRenderer # rubocop:disable Metrics/ClassLength
17
+ def initialize
18
+ @metadata = {
19
+ resources: {},
20
+ entities: {},
21
+ }
22
+ @api_infos = {}
23
+ @api_servers = {}
24
+ @api_paths = {}
25
+ @api_components = {}
26
+ @api_tags = []
27
+ end
28
+
29
+ def merge_context(context)
30
+ @metadata[:resources].deep_merge! context[:resources]
31
+ @metadata[:entities].deep_merge! context[:entities]
32
+ end
33
+
34
+ def write_files
35
+ content = prepare_metadata
36
+ File.write ::Rails.root.join('tmp', 'out.yaml'), content.to_yaml
37
+ File.write ::Rails.root.join('tmp', 'out.json'), content.to_json
38
+ end
39
+
40
+ def prepare_metadata
41
+ # Example: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore-expanded.yaml
42
+ extract_metadatas
43
+ {
44
+ openapi: '3.0.0',
45
+ info: @api_infos,
46
+ servers: @api_servers,
47
+ paths: @api_paths,
48
+ components: @api_components,
49
+ tags: @api_tags,
50
+ }.deep_stringify_keys
51
+ end
52
+
53
+ private
54
+
55
+ def extract_metadatas
56
+ extract_from_resources
57
+ extract_api_infos
58
+ extract_api_servers
59
+ end
60
+
61
+ def extract_from_resources
62
+ @metadata[:resources].each do |resource_key, resource|
63
+ @api_tags.push(
64
+ name: resource_key.to_s,
65
+ description: resource[:description]
66
+ )
67
+ process_resource resource: resource_key, resource_config: resource
68
+ end
69
+ end
70
+
71
+ def process_resource(resource: nil, resource_config: nil) # rubocop:disable Metrics/MethodLength
72
+ resource_config[:paths].each do |path_key, path|
73
+ url = path_with_params path_key.to_s
74
+ actions = {}
75
+ parameters = path.key?(:path_params) ? process_path_params(path[:path_params]) : []
76
+
77
+ path[:actions].each_key do |action|
78
+ next unless %i[get post put patch delete].include? action
79
+
80
+ actions[action] = process_action resource: resource,
81
+ path: path_key,
82
+ path_config: path,
83
+ action_config: action,
84
+ parameters: parameters
85
+ end
86
+
87
+ @api_paths[url] = actions
88
+ end
89
+ end
90
+
91
+ def process_path_params(params)
92
+ parameters = []
93
+ params.each do |name, param|
94
+ parameters.push process_path_param name, param
95
+ end
96
+
97
+ parameters
98
+ end
99
+
100
+ def process_path_param(name, param) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
101
+ parameter = {
102
+ name: name.to_s,
103
+ description: param[:description],
104
+ required: param[:required] || true,
105
+ in: param[:scope].to_s,
106
+ schema: {
107
+ type: PARAM_TYPES[param[:type]][:type],
108
+ },
109
+ }
110
+
111
+ parameter[:schema][:format] = PARAM_TYPES[param[:type]][:format] if PARAM_TYPES[param[:type]][:format]
112
+
113
+ parameter
114
+ end
115
+
116
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
117
+ def process_action(resource: nil, path: nil, path_config: nil, action_config: nil, parameters: nil)
118
+ responses = {}
119
+ request_body = nil
120
+
121
+ if %i[post put patch].include? action_config
122
+ if path_config[:actions][action_config][:params].keys.count.positive?
123
+ schema = path_config[:actions][action_config][:params]
124
+ schema_ref = escape_operation_id("#{action_config}_#{path}")
125
+ request_body = process_request_body schema: schema, ref: schema_ref
126
+ end
127
+ end
128
+
129
+ path_config[:actions][action_config][:statuses].each do |status_key, status|
130
+ content = status[:example][:response]
131
+ responses[status_key] = process_response status: status_key, status_config: status, content: content
132
+ end
133
+
134
+ description = path_config[:actions][action_config][:description]
135
+ action = {
136
+ description: description,
137
+ operationId: "#{resource} #{description}".downcase.gsub(/[^\w]/, '_'),
138
+ parameters: parameters,
139
+ responses: responses,
140
+ tags: [resource.to_s],
141
+ }
142
+
143
+ action[:requestBody] = request_body if request_body
144
+
145
+ action
146
+ end
147
+
148
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
149
+
150
+ def process_request_body(schema: nil, ref: nil)
151
+ Utils.deep_set @api_components, "schemas.#{ref}", schema
152
+ {
153
+ # description: '',
154
+ required: true,
155
+ content: {
156
+ 'application/json' => {
157
+ schema: { '$ref' => "#/components/schemas/#{ref}" },
158
+ },
159
+ },
160
+ }
161
+ end
162
+
163
+ def process_response(status: nil, status_config: nil, content: nil)
164
+ response = {
165
+ description: status_config[:description],
166
+ }
167
+
168
+ return response unless status.to_s != '204' && content # No content
169
+
170
+ response[:content] = {
171
+ 'application/json': {
172
+ examples: { default: { value: JSON.pretty_generate(JSON.parse(content)) } },
173
+ },
174
+ }
175
+
176
+ response
177
+ end
178
+
179
+ def path_with_params(string)
180
+ string.gsub(/(?::(\w*))/) do |e|
181
+ "{#{e.sub(':', '')}}"
182
+ end
183
+ end
184
+
185
+ def escape_operation_id(string)
186
+ string.downcase.gsub(/[^\w]+/, '_')
187
+ end
188
+
189
+ def extract_api_infos # rubocop:disable Metrics/MethodLength
190
+ @api_infos = {
191
+ title: 'Some sample app',
192
+ version: '1.0',
193
+ description: 'A nice API',
194
+ termsOfService: 'https://example.com/tos',
195
+ contact: {
196
+ name: 'API Team',
197
+ email: 'api-team@example.com',
198
+ url: 'http://example.com',
199
+ },
200
+ license: {
201
+ name: 'Apache 2',
202
+ url: 'https://url-to-license.com',
203
+ },
204
+ }
205
+ end
206
+
207
+ def extract_api_servers
208
+ @api_servers = [
209
+ { url: 'http://api.example.com' },
210
+ ]
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/hash_with_indifferent_access'
4
+
5
+ module RSpec
6
+ module Rails
7
+ module Api
8
+ # Helper methods
9
+ class Utils
10
+ def self.deep_get(hash, path)
11
+ path.split('.').inject(hash) do |sub_hash, key|
12
+ return nil unless sub_hash.is_a?(Hash) && sub_hash.key?(key.to_sym)
13
+
14
+ sub_hash[key.to_sym]
15
+ end
16
+ end
17
+
18
+ def self.deep_set(hash, path, value)
19
+ path = path.split('.') unless path.is_a? Array
20
+
21
+ return value if path.count.zero?
22
+
23
+ current_key = path.shift.to_sym
24
+ hash[current_key] = {} unless hash[current_key].is_a?(Hash)
25
+ hash[current_key] = deep_set(hash[current_key], path, value)
26
+
27
+ hash
28
+ end
29
+
30
+ def self.check_value_type(type, value) # rubocop:disable Metrics/CyclomaticComplexity
31
+ return true if type == :boolean && (value.is_a?(TrueClass) || value.is_a?(FalseClass))
32
+ return true if type == :array && value.is_a?(Array)
33
+
34
+ raise "Unknown type #{type}" unless PARAM_TYPES.key? type
35
+
36
+ value.is_a? PARAM_TYPES[type][:class]
37
+ end
38
+
39
+ def self.validate_object_structure(actual, expected)
40
+ # Check keys
41
+ return false unless same_keys? actual, expected
42
+
43
+ expected.each_key do |key|
44
+ next unless expected[key][:required]
45
+
46
+ expected_type = expected[key][:type]
47
+ expected_attributes = expected[key][:attributes]
48
+
49
+ # Type
50
+ return false unless check_value_type expected_type, actual[key.to_s]
51
+
52
+ # Deep object ?
53
+ return false unless validate_deep_object expected_type, expected_attributes, actual[key.to_s]
54
+ end
55
+
56
+ true
57
+ end
58
+
59
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
60
+ def self.validate_deep_object(expected_type, expected_attributes, actual)
61
+ if %i[object array].include?(expected_type) && expected_attributes.is_a?(Hash)
62
+ case expected_type
63
+ when :object
64
+ return false unless validate_object_structure actual, expected_attributes
65
+ when :array
66
+ actual.each do |array_entry|
67
+ return false unless validate_object_structure array_entry, expected_attributes
68
+ end
69
+ end
70
+ end
71
+
72
+ true
73
+ end
74
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
75
+
76
+ def self.same_keys?(actual, expected)
77
+ optional = expected.reject { |_key, value| value[:required] }.keys
78
+ actual.symbolize_keys.keys.sort - optional == expected.keys.sort - optional
79
+ end
80
+
81
+ def self.check_attribute_type(type, except: [])
82
+ keys = PARAM_TYPES.keys.reject { |key| except.include? key }
83
+ keys.include?(type)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end