rspec-rails-api 0.4.0 → 0.6.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.
@@ -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