rspec-rails-api 0.1.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,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