rspec-rails-api 0.4.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -9,14 +9,15 @@ module RSpec
9
9
  ##
10
10
  # Visits the current example and tests the response
11
11
  #
12
- # @param example [Hash] Current example
13
- # @param path_params [Hash] Path parameters definition
14
- # @param payload [Hash] Request body
15
- # @param headers [Hash] Custom headers
12
+ # @param example [Hash] Current example
13
+ # @param path_params [Hash] Path parameters definition
14
+ # @param payload [Hash] Request body
15
+ # @param headers [Hash] Custom headers
16
+ # @param ignore_content_type [Boolean] Whether to ignore the response's content-type for this response only
16
17
  #
17
18
  # @return [void]
18
- def test_response_of(example, path_params: {}, payload: {}, headers: {}) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
19
- raise 'Missing context. Call visit with for_code context.' unless example
19
+ def test_response_of(example, path_params: {}, payload: {}, headers: {}, ignore_content_type: false, ignore_response: false) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists, Layout/LineLength
20
+ raise 'Missing context. Call "test_response_of" within a "for_code" block.' unless example
20
21
 
21
22
  status_code = prepare_status_code example.class.description
22
23
 
@@ -25,10 +26,10 @@ module RSpec
25
26
 
26
27
  send(request_params[:action],
27
28
  request_params[:url],
28
- params: request_params[:params].to_json,
29
+ params: request_params[:params],
29
30
  headers: request_params[:headers])
30
31
 
31
- check_response(response, status_code)
32
+ check_response(response, status_code, ignore_content_type: ignore_content_type) unless ignore_response
32
33
 
33
34
  return if example.class.description.match?(/-> test (\d+)(.*)/)
34
35
 
@@ -38,23 +39,24 @@ module RSpec
38
39
  private
39
40
 
40
41
  ##
41
- # Searches for a defined entity in metadata
42
+ # Searches for a defined entity in example metadata or global entities
43
+ #
44
+ # If an entity needs expansion (e.g.: with an attribute like "type: :array, of: :something"), it will use the
45
+ # scope where the entity was found: global entities or example metadata.
42
46
  #
43
47
  # @param entity [Symbol] Entity reference
44
48
  #
45
49
  # @return [RSpec::Rails::Api::EntityConfig, Hash] Defined entity
46
50
  def defined(entity)
47
- return { type: entity.to_s.split('_').last.to_sym } if PRIMITIVES.include? entity
51
+ return { type: entity } if PRIMITIVES.include? entity
48
52
 
49
53
  current_resource = rra_metadata.current_resource
50
54
  raise '@current_resource is unset' unless current_resource
51
55
 
52
- entities = rra_metadata.resources[current_resource][:entities]
53
-
54
- out = entities[entity]
55
- raise "Unknown entity '#{entity}' in resource '#{current_resource}'" unless out
56
+ definition = RSpec::Rails::Api::Metadata.entities[entity.to_sym]
57
+ raise "Entity '#{entity}' was never defined (globally or in '#{current_resource}')" unless definition
56
58
 
57
- out.expand_with(entities)
59
+ definition.expand_with(RSpec::Rails::Api::Metadata.entities)
58
60
  end
59
61
 
60
62
  ##
@@ -62,9 +64,21 @@ module RSpec
62
64
  #
63
65
  # @param response [ActionDispatch::TestResponse] The response
64
66
  # @param expected_code [Number] Code to test for
65
- def check_response(response, expected_code) # rubocop:disable Metrics/AbcSize
66
- expect(response.status).to eq expected_code
67
- expect(response.headers['Content-Type']).to eq 'application/json; charset=utf-8' if expected_code != 204
67
+ # @param ignore_content_type [Boolean] Whether to ignore the response's content-type for
68
+ # this response only
69
+ def check_response(response, expected_code, ignore_content_type: false) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
70
+ code_error_message = if response.status != expected_code && response.status == 422
71
+ <<~TXT
72
+ expected: #{expected_code}
73
+ got: #{response.status}
74
+ response: #{response.body}
75
+ TXT
76
+ end
77
+
78
+ expect(response.status).to eq(expected_code), code_error_message
79
+ if expected_code != 204 && !ignore_content_type
80
+ expect(response.headers['Content-Type'].downcase).to eq 'application/json; charset=utf-8'
81
+ end
68
82
  expectations = rra_current_example[:expectations]
69
83
  expect(response).to have_many defined(expectations[:many]) if expectations[:many]
70
84
  expect(response).to have_one defined(expectations[:one]) if expectations[:one]
@@ -100,9 +114,12 @@ module RSpec
100
114
  # @return [Hash] Options for the request
101
115
  def prepare_request_params(description, request_params = {}, payload = {}, request_headers = {})
102
116
  example_params = description.split
117
+ verb = example_params[0].downcase
118
+
119
+ payload = payload.to_json if verb != 'get'
103
120
 
104
121
  {
105
- action: example_params[0].downcase,
122
+ action: verb,
106
123
  url: prepare_request_url(example_params[1], request_params),
107
124
  example_url: example_params[1],
108
125
  params: payload,
@@ -28,7 +28,7 @@ module RSpec
28
28
  #
29
29
  # @return [void]
30
30
  def entity(type, fields)
31
- metadata[:rra].add_entity type, fields
31
+ RSpec::Rails::Api::Metadata.add_entity type, fields
32
32
  end
33
33
 
34
34
  ##
@@ -78,6 +78,16 @@ module RSpec
78
78
  metadata[:rra].add_request_params attributes
79
79
  end
80
80
 
81
+ ##
82
+ # Declares security schemes valid for this path. It won't be enforced during testing but will complete the
83
+ # documentation. When the reference does not exist, an exception will be thrown _during_ render, not before.
84
+ #
85
+ # @param scheme_references [Array<Symbol>] References to a security scheme defined with the renderer's
86
+ # `add_security_scheme`.
87
+ def requires_security(*scheme_references)
88
+ metadata[:rra].add_security_references(*scheme_references)
89
+ end
90
+
81
91
  ##
82
92
  # Defines a GET action
83
93
  #
@@ -186,7 +196,7 @@ module RSpec
186
196
  #
187
197
  # @return [void]
188
198
  def execute_for_code_block(callback_block)
189
- example 'Test and create documentation', caller: callback_block.send(:caller) do
199
+ example 'Test response and create documentation', caller: callback_block.send(:caller) do
190
200
  instance_eval(&callback_block) if callback_block
191
201
  end
192
202
  end
@@ -13,8 +13,8 @@ module RSpec
13
13
 
14
14
  def initialize(fields)
15
15
  @fields = {}
16
- fields.each_key do |name|
17
- @fields[name] = FieldConfig.new fields[name]
16
+ fields.each_pair do |name, definition|
17
+ @fields[name] = FieldConfig.new(**definition)
18
18
  end
19
19
  end
20
20
 
@@ -55,15 +55,12 @@ module RSpec
55
55
  # @param attribute [Symbol] Attribute name
56
56
  # @param entities [Hash] List of entities
57
57
  def expand_attribute(attribute, entities)
58
- if PRIMITIVES.include? attribute
59
- # Primitives support
60
- { type: attribute.to_s.split('_').last.to_sym }
61
- else
62
- # Defined attribute
63
- raise "Entity #{attribute} not found for entity completion." unless entities[attribute]
58
+ # Primitives support
59
+ return { type: attribute } if PRIMITIVES.include? attribute
64
60
 
65
- entities[attribute].expand_with(entities)
66
- end
61
+ raise "Entity #{attribute} not found for entity completion." unless entities[attribute]
62
+
63
+ entities[attribute].expand_with(entities)
67
64
  end
68
65
  end
69
66
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rspec/rails/api/entity_config'
4
- require 'rspec/rails/api/utils'
4
+ require 'rspec/rails/api/validator'
5
5
 
6
6
  module RSpec
7
7
  module Rails
@@ -11,10 +11,13 @@ module RSpec
11
11
  class FieldConfig
12
12
  attr_accessor :required, :type, :attributes, :description
13
13
 
14
- def initialize(type:, description:, required: true, attributes: nil, of: nil)
14
+ def initialize(type:, description:, required: true, attributes: nil, of: nil) # rubocop:disable Metrics/CyclomaticComplexity
15
15
  @required = required
16
16
  @description = description
17
- raise "Field type not allowed: '#{type}'" unless Utils.check_attribute_type(type)
17
+
18
+ raise "Field type not allowed: '#{type}'" unless Validator.valid_type?(type)
19
+ raise "Don't use 'of' on non-arrays" if of && type != :array
20
+ raise "Don't use 'attributes' on non-objects" if attributes && type != :object
18
21
 
19
22
  define_attributes attributes if type == :object
20
23
  define_attributes of if type == :array
@@ -1,53 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/hash_with_indifferent_access'
4
+ require 'yaml'
4
5
 
6
+ require 'rspec/rails/api/validator'
5
7
  require 'rspec/rails/api/utils'
6
8
 
7
9
  ##
8
10
  # RSpec matcher to check something against an array of `expected`
9
- #
10
- # FIXME: Split the matcher in something else; it's too messy.
11
11
  RSpec::Matchers.define :have_many do |expected|
12
12
  match do |actual|
13
- @actual = actual
14
- @actual = JSON.parse(actual.body) if actual.respond_to? :body
13
+ actual = RSpec::Rails::Api::Utils.hash_from_response actual
15
14
 
16
- raise "Response is not an array: #{@actual.class}" unless @actual.is_a? Array
17
- raise 'Response has no item to compare with' unless @actual.count.positive?
15
+ raise "Response is not an array: #{actual.class}" unless actual.is_a? Array
16
+ raise 'Response has no item to compare with' unless actual.count.positive?
18
17
 
19
- # Primitive type
20
- if expected[:type].is_a?(Symbol)
21
- @actual.each do |item|
22
- return false unless RSpec::Rails::Api::Utils.check_value_type(expected[:type], item)
23
- end
18
+ @errors = RSpec::Rails::Api::Validator.validate_array actual, expected
24
19
 
25
- return true
26
- end
27
-
28
- # Check every entry
29
- @actual.each do |item|
30
- return false unless RSpec::Rails::Api::Utils.validate_object_structure item, expected
31
- end
32
-
33
- true
20
+ @errors.blank?
34
21
  end
35
22
 
36
- diffable
23
+ failure_message do |actual|
24
+ object = RSpec::Rails::Api::Utils.hash_from_response(actual).to_json.chomp
25
+ RSpec::Rails::Api::Validator.format_failure_message @errors, object
26
+ end
37
27
  end
38
28
 
39
29
  ##
40
30
  # RSpec matcher to check something against the `expected` definition
41
- # FIXME: Split the matcher in something else; it's too messy.
42
31
  RSpec::Matchers.define :have_one do |expected|
43
32
  match do |actual|
44
- @actual = actual
45
- @actual = JSON.parse(actual.body) if actual.respond_to? :body
33
+ actual = RSpec::Rails::Api::Utils.hash_from_response actual
46
34
 
47
- raise "Response is not a hash: #{@actual.class}" unless @actual.is_a? Hash
35
+ @errors = if expected.keys.count == 1 && expected.key?(:type)
36
+ RSpec::Rails::Api::Validator.validate_type actual, expected[:type]
37
+ else
38
+ RSpec::Rails::Api::Validator.validate_object actual, expected
39
+ end
48
40
 
49
- RSpec::Rails::Api::Utils.validate_object_structure @actual, expected
41
+ @errors.blank?
50
42
  end
51
43
 
52
- diffable
44
+ failure_message do |actual|
45
+ object = RSpec::Rails::Api::Utils.hash_from_response(actual).to_json.chomp
46
+ RSpec::Rails::Api::Validator.format_failure_message @errors, object
47
+ end
53
48
  end
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rspec/rails/api/utils'
4
+ require 'rspec/rails/api/validator'
4
5
  require 'rspec/rails/api/open_api_renderer'
5
6
  require 'rspec/rails/api/entity_config'
6
7
 
7
8
  module RSpec
8
9
  module Rails
9
10
  module Api
10
- # Handles contexts and examples metadatas.
11
+ # Handles contexts and examples metadata.
11
12
  class Metadata # rubocop:disable Metrics/ClassLength
12
- attr_reader :entities, :resources, :parameters, :current_resource, :current_url, :current_method, :current_code
13
+ attr_reader :resources, :parameters, :current_resource, :current_url, :current_method, :current_code
13
14
 
14
15
  def initialize
15
16
  @resources = {}
16
- @entities = {}
17
17
  @parameters = {}
18
18
  # Only used when building metadata during RSpec boot
19
19
  @current_resource = nil
@@ -22,6 +22,33 @@ module RSpec
22
22
  @current_code = nil
23
23
  end
24
24
 
25
+ class << self
26
+ ##
27
+ # Define an entity globally.
28
+ #
29
+ # Global entities will be available within the specs, but if they are re-declared locally, the local variant
30
+ # will be used.
31
+ #
32
+ # @param name [Symbol] Entity name
33
+ # @param fields [Hash] Fields definitions
34
+ #
35
+ # @return [void]
36
+ def add_entity(name, fields)
37
+ @entities ||= {}
38
+ raise "#{name} is already declared" if @entities.key? name
39
+
40
+ @entities[name] = EntityConfig.new fields
41
+ end
42
+
43
+ def entities
44
+ @entities || {}
45
+ end
46
+
47
+ def reset
48
+ @entities = {}
49
+ end
50
+ end
51
+
25
52
  ##
26
53
  # Adds a resource to metadata
27
54
  #
@@ -30,24 +57,10 @@ module RSpec
30
57
  #
31
58
  # @return [void]
32
59
  def add_resource(name, description)
33
- @resources[name.to_sym] = { description: description, paths: {} }
60
+ @resources[name.to_sym] ||= { description: description, paths: {} }
34
61
  @current_resource = name.to_sym
35
62
  end
36
63
 
37
- ##
38
- # Adds an entity definition
39
- #
40
- # @param name [Symbol] Entity name
41
- # @param fields [Hash] Fields definitions
42
- #
43
- #
44
- # @return [void]
45
- def add_entity(name, fields)
46
- Utils.deep_set(@resources,
47
- "#{@current_resource}.entities.#{name}",
48
- EntityConfig.new(fields))
49
- end
50
-
51
64
  ##
52
65
  # Adds a parameter definition
53
66
  #
@@ -60,6 +73,7 @@ module RSpec
60
73
 
61
74
  fields.each_value do |field|
62
75
  field[:required] = true unless field[:required] == false
76
+ field[:schema] = { type: field[:of] } if field[:type] == :array && PRIMITIVES.include?(field[:of])
63
77
  end
64
78
  @parameters[name] = fields
65
79
  end
@@ -76,11 +90,11 @@ module RSpec
76
90
  chunks = @current_url.split('?')
77
91
 
78
92
  fields.each do |name, field|
79
- valid_attribute = Utils.check_attribute_type(field[:type], except: %i[array object])
93
+ valid_attribute = Validator.valid_type?(field[:type], except: %i[array object])
80
94
  raise "Field type not allowed: #{field[:type]}" unless valid_attribute
81
95
 
82
96
  scope = path_param_scope(chunks, name)
83
- Utils.deep_set(@resources, "#{@current_resource}.paths.#{@current_url}.path_params.#{name}",
97
+ Utils.deep_set(@resources, [@current_resource, 'paths', @current_url, 'path_params', name],
84
98
  description: field[:description] || nil,
85
99
  type: field[:type] || nil,
86
100
  required: field[:required] || true,
@@ -109,10 +123,24 @@ module RSpec
109
123
 
110
124
  params = organize_params fields
111
125
  Utils.deep_set(@resources,
112
- "#{@current_resource}.paths.#{@current_url}.actions.#{@current_method}.params",
126
+ [@current_resource, 'paths', @current_url, 'actions', @current_method, 'params'],
113
127
  params)
114
128
  end
115
129
 
130
+ # Associate a defined security scheme to this request
131
+ #
132
+ # @param references [Array<Symbol>] Security scheme reference
133
+ def add_security_references(*references)
134
+ check_current_context :resource, :url, :method
135
+
136
+ refs = @resources.dig @current_resource, 'paths', @current_url, 'actions', @current_method, 'security'
137
+ refs ||= []
138
+ refs += references
139
+ Utils.deep_set(@resources,
140
+ [@current_resource, 'paths', @current_url, 'actions', @current_method, 'security'],
141
+ refs)
142
+ end
143
+
116
144
  ##
117
145
  # Adds an action and sets `@current_url` and `@current_method`
118
146
  #
@@ -125,7 +153,7 @@ module RSpec
125
153
  def add_action(method, url, summary, description = '')
126
154
  check_current_context :resource
127
155
 
128
- Utils.deep_set(@resources, "#{@current_resource}.paths.#{url}.actions.#{method}",
156
+ Utils.deep_set(@resources, [@current_resource, 'paths', url, 'actions', method],
129
157
  description: description || '',
130
158
  summary: summary,
131
159
  statuses: {},
@@ -148,7 +176,7 @@ module RSpec
148
176
  check_current_context :resource, :url, :method
149
177
 
150
178
  Utils.deep_set(@resources,
151
- "#{@current_resource}.paths.#{@current_url}.actions.#{@current_method}.statuses.#{status_code}",
179
+ [@current_resource, 'paths', @current_url, 'actions', @current_method, 'statuses', status_code],
152
180
  description: description,
153
181
  example: { response: nil })
154
182
  @current_code = status_code
@@ -160,10 +188,13 @@ module RSpec
160
188
  #
161
189
  # @return [Hash] Current example metadata
162
190
  def current_example
163
- # rubocop:disable Layout/LineLength
164
- Utils.deep_get @resources,
165
- "#{@current_resource}.paths.#{@current_url}.actions.#{@current_method}.statuses.#{@current_code}"
166
- # rubocop:enable Layout/LineLength
191
+ @resources.dig @current_resource,
192
+ :paths,
193
+ @current_url.to_sym,
194
+ :actions,
195
+ @current_method.to_sym,
196
+ :statuses,
197
+ @current_code.to_s.to_sym
167
198
  end
168
199
 
169
200
  ##
@@ -179,7 +210,7 @@ module RSpec
179
210
 
180
211
  # rubocop:disable Layout/LineLength
181
212
  Utils.deep_set(@resources,
182
- "#{@current_resource}.paths.#{@current_url}.actions.#{@current_method}.statuses.#{@current_code}.expectations",
213
+ [@current_resource, 'paths', @current_url, 'actions', @current_method, 'statuses', @current_code, 'expectations'],
183
214
  {
184
215
  one: one,
185
216
  many: many,
@@ -195,20 +226,20 @@ module RSpec
195
226
  # @param action [String, nil] HTTP verb
196
227
  # @param status_code [Integer, nil] Status code
197
228
  # @param response [String, nil] Response body
198
- # @param path_params [Hash, nil] Used path parameterss
229
+ # @param path_params [Hash, nil] Used path parameters
199
230
  # @param params [Hash, nil] Used body parameters
200
231
  #
201
232
  # rubocop:disable Metrics/ParameterLists
202
233
  def add_request_example(url: nil, action: nil, status_code: nil, response: nil, path_params: nil, params: nil)
203
234
  resource = nil
204
235
  @resources.each do |key, res|
205
- resource = key if Utils.deep_get(res, "paths.#{url}.actions.#{action}.statuses.#{status_code}")
236
+ resource = key if res.dig :paths, url.to_sym, :actions, action.to_sym, :statuses, status_code.to_s.to_sym
206
237
  end
207
238
 
208
239
  raise "Resource not found for #{action.upcase} #{url}" unless resource
209
240
 
210
241
  Utils.deep_set(@resources,
211
- "#{resource}.paths.#{url}.actions.#{action}.statuses.#{status_code}.example",
242
+ [resource, 'paths', url, 'actions', action, 'statuses', status_code, 'example'],
212
243
  path_params: path_params,
213
244
  params: params,
214
245
  response: response)
@@ -261,12 +292,16 @@ module RSpec
261
292
  ##
262
293
  # Checks and complete a field definition
263
294
  #
264
- # @param fields [Hash] Fields definitions
295
+ # @param fields [Hash,Symbol] Fields definitions
265
296
  #
266
- # @return [Hash] Completed field definition
267
- def organize_params(fields) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
297
+ # @return [Hash,Symbol] Completed field definition
298
+ def organize_params(fields) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
299
+ return fields if fields.is_a?(Symbol) && PRIMITIVES.include?(fields)
300
+ raise "Unsupported type \"#{fields}\"" unless fields.is_a? Hash
301
+
268
302
  out = { properties: {} }
269
303
  required = []
304
+
270
305
  allowed_types = %i[array object]
271
306
  fields.each do |name, field|
272
307
  allowed_type = allowed_types.include?(field[:type]) || PARAM_TYPES.key?(field[:type])
@@ -286,7 +321,7 @@ module RSpec
286
321
  # @param field [Hash] Parameter definition
287
322
  #
288
323
  # @return [Hash] Completed parameter
289
- def fill_request_param(field)
324
+ def fill_request_param(field) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
290
325
  if field[:type] == :object && field[:attributes]
291
326
  organize_params field[:attributes]
292
327
  else
@@ -294,6 +329,7 @@ module RSpec
294
329
  type: PARAM_TYPES[field[:type]][:type],
295
330
  description: field[:description] || nil,
296
331
  }
332
+ properties[:format] = PARAM_TYPES[field[:type]][:format] if PARAM_TYPES[field[:type]][:format]
297
333
 
298
334
  properties[:items] = organize_params field[:of] if field[:type] == :array && field[:of]
299
335
  properties