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.
@@ -6,7 +6,7 @@ require 'active_support'
6
6
  module RSpec
7
7
  module Rails
8
8
  module Api
9
- # Class to render metadatas.
9
+ # Class to render metadata.
10
10
  # Example:
11
11
  # ```rb
12
12
  # renderer = RSpec::Rails::Api::OpenApiRenderer.new
@@ -15,16 +15,36 @@ module RSpec
15
15
  # ```
16
16
  class OpenApiRenderer # rubocop:disable Metrics/ClassLength
17
17
  attr_writer :api_servers, :api_title, :api_version, :api_description, :api_tos
18
+ attr_reader :redactables
18
19
 
19
20
  def initialize
20
- @metadata = { resources: {}, entities: {} }
21
- @api_infos = {}
22
- @api_servers = []
23
- @api_paths = {}
21
+ @metadata = { resources: {}, entities: {} }
22
+ @api_infos = {}
23
+ @api_servers = []
24
+ @api_paths = {}
24
25
  @api_components = {}
25
26
  @api_tags = []
26
27
  @api_contact = {}
27
28
  @api_license = {}
29
+ @api_security = {}
30
+ @redactables = {}
31
+ end
32
+
33
+ def redact_responses(pairs)
34
+ @redactables = pairs
35
+ end
36
+
37
+ ##
38
+ # Adds a security scheme definition to the API documentation
39
+ #
40
+ # @param reference [Symbol] Reference to use in the tests
41
+ # @param name [String] Human friendly name
42
+ # @param definition [Hash] Security scheme definition as per https://swagger.io/specification/#security-scheme-object
43
+ def add_security_scheme(reference, name, definition)
44
+ raise "Security scheme #{reference} is already defined" if @api_security.key? reference
45
+
46
+ definition[:name] = name
47
+ @api_security[reference] = definition
28
48
  end
29
49
 
30
50
  ##
@@ -35,11 +55,10 @@ module RSpec
35
55
  #
36
56
  # @return [void
37
57
  def merge_context(context, dump_metadata: false)
38
- @metadata[:resources].deep_merge! context[:resources]
39
- @metadata[:entities].deep_merge! context[:entities]
58
+ @metadata[:resources].deep_merge! context.respond_to?(:resources) ? context.resources : context[:resources]
40
59
 
41
60
  # Save context for debug and fixtures
42
- File.write ::Rails.root.join('tmp', 'rra_metadata.yaml'), context.to_yaml if dump_metadata
61
+ File.write ::Rails.root.join('tmp', 'rra_metadata.yaml'), @metadata.to_yaml if dump_metadata
43
62
  end
44
63
 
45
64
  ##
@@ -50,14 +69,20 @@ module RSpec
50
69
  #
51
70
  # @return [void]
52
71
  def write_files(path = nil, only: %i[yaml json])
72
+ return unless write_file? RSpec.world.filtered_examples
73
+
53
74
  path ||= ::Rails.root.join('tmp', 'rspec_api_rails')
54
75
 
55
- file_types = %i[yaml json]
76
+ metadata = prepare_metadata
56
77
 
78
+ file_types = %i[yaml json]
57
79
  only.each do |type|
58
80
  next unless file_types.include? type
59
81
 
60
- File.write "#{path}.#{type}", prepare_metadata.send("to_#{type}")
82
+ data = metadata.to_yaml if type == :yaml
83
+ data = JSON.pretty_generate(metadata) if type == :json
84
+
85
+ File.write "#{path}.#{type}", data
61
86
  end
62
87
  end
63
88
 
@@ -66,7 +91,7 @@ module RSpec
66
91
  #
67
92
  # @return [Hash] The OpenAPI structure
68
93
  def prepare_metadata
69
- extract_metadatas
94
+ extract_metadata
70
95
  # Example: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore-expanded.yaml
71
96
  hash = {
72
97
  openapi: '3.0.0',
@@ -76,7 +101,7 @@ module RSpec
76
101
  components: @api_components,
77
102
  tags: @api_tags,
78
103
  }
79
- JSON.parse(JSON.pretty_generate(hash))
104
+ JSON.parse(hash.to_json)
80
105
  end
81
106
 
82
107
  ##
@@ -107,29 +132,50 @@ module RSpec
107
132
 
108
133
  private
109
134
 
135
+ def write_file?(examples)
136
+ acceptance_examples = examples.values.flatten.filter do |e|
137
+ e.metadata[:type] == :acceptance
138
+ end
139
+ unless acceptance_examples.none?(&:exception)
140
+ puts "\n\e[00;31mSome acceptance tests failed. OpenApi specification file was not updated.\n\e[00m"
141
+ return false
142
+ end
143
+
144
+ true
145
+ end
146
+
110
147
  ##
111
148
  # Extracts metadata for rendering
112
149
  #
113
150
  # @return [void]
114
- def extract_metadatas
115
- extract_from_resources
151
+ def extract_metadata
152
+ extract_security
116
153
  api_infos
117
154
  api_servers
155
+ global_entities
156
+ extract_from_resources
157
+ end
158
+
159
+ ##
160
+ # Extracts metadata from security schemes for rendering
161
+ #
162
+ # @return [void]
163
+ def extract_security
164
+ return unless @api_security.keys.count.positive?
165
+
166
+ @api_components['securitySchemes'] = @api_security
118
167
  end
119
168
 
120
169
  ##
121
170
  # Extracts metadata from resources for rendering
122
171
  #
123
172
  # @return [void]
124
- def extract_from_resources # rubocop:disable Metrics/MethodLength
173
+ def extract_from_resources
125
174
  @api_components[:schemas] ||= {}
126
175
  @metadata[:resources].each do |resource_key, resource|
127
- resource[:entities].each do |name, entity|
128
- @api_components[:schemas][name] = process_entity(entity)
129
- end
130
176
  @api_tags.push(
131
177
  name: resource_key.to_s,
132
- description: resource[:description]
178
+ description: resource[:description].presence&.strip || ''
133
179
  )
134
180
  process_resource resource: resource_key, resource_config: resource
135
181
  end
@@ -177,24 +223,29 @@ module RSpec
177
223
  parameters
178
224
  end
179
225
 
180
- def process_entity(entity) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
226
+ def process_entity(entity) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
181
227
  schema = {
182
228
  properties: {},
183
229
  }
184
230
  required = []
185
231
  entity.fields.each do |name, field|
186
232
  property = {
187
- description: field.description,
233
+ description: field.description.presence&.strip || '',
188
234
  type: PARAM_TYPES[field.type][:type],
189
235
  }
190
- property[:format] = PARAM_TYPES[field.type][:format] if PARAM_TYPES[field.type][:format]
191
- schema[:properties][name] = property
192
- # Primitives support
236
+ property[:format] = PARAM_TYPES[field.type][:format] if PARAM_TYPES[field.type][:format]
237
+
193
238
  if PRIMITIVES.include? field.attributes
194
- property[:items] =
195
- { type: field.attributes.to_s.split('_').last.to_sym }
239
+ property[:items] = { type: field.attributes }
240
+ elsif field.type == :object && field.attributes.is_a?(Symbol)
241
+ property = { '$ref' => "#/components/schemas/#{field.attributes}" }
242
+ elsif field.type == :array && field.attributes.is_a?(Symbol)
243
+ property = { type: :array, items: { '$ref' => "#/components/schemas/#{field.attributes}" } }
196
244
  end
245
+
197
246
  required.push name unless field.required == false
247
+
248
+ schema[:properties][name] = property
198
249
  end
199
250
 
200
251
  schema[:required] = required unless required.size.zero?
@@ -210,10 +261,10 @@ module RSpec
210
261
  #
211
262
  #
212
263
  # @return [void]
213
- def process_path_param(name, param) # rubocop:disable Metrics/MethodLength
264
+ def process_path_param(name, param) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
214
265
  parameter = {
215
266
  name: name.to_s,
216
- description: param[:description],
267
+ description: param[:description].presence&.strip || '',
217
268
  required: param[:required] || true,
218
269
  in: param[:scope].to_s,
219
270
  schema: {
@@ -239,7 +290,7 @@ module RSpec
239
290
  #
240
291
  # FIXME: Rename "action_config" to "action"
241
292
  # FIXME: Rename "parameters" to "path_parameters"
242
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
293
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
243
294
  def process_action(resource: nil, path: nil, path_config: nil, action_config: nil, parameters: nil)
244
295
  responses = {}
245
296
  request_body = nil
@@ -257,21 +308,31 @@ module RSpec
257
308
  responses[status_key] = process_response status: status_key, status_config: status, content: content
258
309
  end
259
310
 
260
- summary = path_config[:actions][action_config][:summary]
261
- action = {
262
- summary: summary,
263
- description: path_config[:actions][action_config][:description],
264
- operationId: "#{resource} #{summary}".downcase.gsub(/[^\w]/, '_'),
311
+ action = {
312
+ summary: path_config[:actions][action_config][:summary]&.strip || '',
313
+ description: path_config[:actions][action_config][:description].presence&.strip || '',
314
+ operationId: "#{resource} #{action_config} #{path}".downcase.gsub(/[^\w]/, '_'),
265
315
  parameters: parameters,
266
316
  responses: responses,
267
317
  tags: [resource.to_s],
268
318
  }
269
319
 
320
+ if path_config[:actions][action_config].key? :security
321
+ references = path_config[:actions][action_config][:security]
322
+
323
+ action[:security] = []
324
+ references.each do |reference|
325
+ raise "No security scheme defined with reference #{reference}" unless @api_security.key? reference
326
+
327
+ action[:security].push({ reference => [] })
328
+ end
329
+ end
330
+
270
331
  action[:requestBody] = request_body if request_body
271
332
 
272
333
  action
273
334
  end
274
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
335
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
275
336
 
276
337
  ##
277
338
  # Processes a request body from metadata
@@ -282,12 +343,13 @@ module RSpec
282
343
  #
283
344
  # @return [void]
284
345
  def process_request_body(schema: nil, ref: nil, examples: {})
285
- Utils.deep_set @api_components, "schemas.#{ref}", schema
346
+ Utils.deep_set @api_components, ['schemas', ref], schema
347
+
286
348
  {
287
349
  # description: '',
288
350
  required: true,
289
351
  content: {
290
- 'application/json' => {
352
+ content_type_from_schema(schema) => {
291
353
  schema: { '$ref' => "#/components/schemas/#{ref}" },
292
354
  examples: examples,
293
355
  },
@@ -295,6 +357,23 @@ module RSpec
295
357
  }
296
358
  end
297
359
 
360
+ def content_type_from_schema(schema)
361
+ schema_includes_file?(schema) ? 'multipart/form-data' : 'application/json'
362
+ end
363
+
364
+ def schema_includes_file?(schema)
365
+ return true if schema[:type] == 'string' && schema[:format] == 'binary'
366
+ return false unless schema[:properties].is_a?(Hash) && schema[:required].is_a?(Array)
367
+
368
+ schema[:properties].each_value do |definition|
369
+ next unless schema_includes_file?(definition)
370
+
371
+ return true
372
+ end
373
+
374
+ false
375
+ end
376
+
298
377
  ##
299
378
  # Process a response from metadata
300
379
  #
@@ -303,31 +382,58 @@ module RSpec
303
382
  # @param content [String] Response content
304
383
  #
305
384
  # @return [void]
306
- def process_response(status: nil, status_config: nil, content: nil)
307
- response = { description: status_config[:description] }
385
+ def process_response(status: nil, status_config: nil, content: nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
386
+ response = { description: status_config[:description].presence&.strip || '' }
308
387
 
309
388
  return response if status.to_s == '204' && content # No content
310
389
 
390
+ data = begin
391
+ JSON.parse(content)
392
+ rescue JSON::ParserError, TypeError
393
+ content
394
+ end
395
+
396
+ entity = status_config[:expectations][:one] || status_config[:expectations][:many]
397
+
398
+ # TODO: handle sub-entities
399
+ if @redactables.key?(entity) && data.is_a?(Hash)
400
+ if status_config[:expectations][:one]
401
+ @redactables[entity].each_pair do |attribute, replacement|
402
+ data[attribute.to_s] = replacement
403
+ end
404
+ else
405
+ data.each_index do |index|
406
+ @redactables[entity].each_pair do |attribute, replacement|
407
+ data[index][attribute.to_s] = replacement
408
+ end
409
+ end
410
+ end
411
+ end
412
+
311
413
  response[:content] = {
312
414
  'application/json': {
313
415
  schema: response_schema(status_config[:expectations]),
314
- examples: { default: { value: JSON.parse(content) } },
416
+ examples: { default: { value: data } },
315
417
  },
316
418
  }
317
419
 
318
420
  response
319
421
  end
320
422
 
321
- def response_schema(expectations)
423
+ def response_schema(expectations) # rubocop:disable Metrics/MethodLength
322
424
  if expectations[:many]
323
425
  items = if PRIMITIVES.include?(expectations[:many])
324
- { type: expectations[:many].to_s.split('_').last }
426
+ { type: expectations[:many] }
325
427
  else
326
428
  { '$ref' => "#/components/schemas/#{expectations[:many]}" }
327
429
  end
328
430
  { type: 'array', items: items }
329
431
  elsif expectations[:one]
330
- { '$ref' => "#/components/schemas/#{expectations[:one]}" }
432
+ if PRIMITIVES.include?(expectations[:one])
433
+ { type: expectations[:one] }
434
+ else
435
+ { '$ref' => "#/components/schemas/#{expectations[:one]}" }
436
+ end
331
437
  end
332
438
  end
333
439
 
@@ -350,6 +456,16 @@ module RSpec
350
456
  request_examples
351
457
  end
352
458
 
459
+ def global_entities
460
+ return if RSpec::Rails::Api::Metadata.entities.keys.count.zero?
461
+
462
+ @api_components[:schemas] = {}
463
+
464
+ RSpec::Rails::Api::Metadata.entities.each_pair do |name, entity|
465
+ @api_components[:schemas][name] = process_entity(entity)
466
+ end
467
+ end
468
+
353
469
  ##
354
470
  # Converts path with params like ":id" to their OpenAPI representation
355
471
  #
@@ -376,12 +492,12 @@ module RSpec
376
492
  # Fills the API general information sections
377
493
  #
378
494
  # @return [void]
379
- def api_infos
495
+ def api_infos # rubocop:disable Metrics/CyclomaticComplexity
380
496
  @api_infos = {
381
497
  title: @api_title || 'Some sample app',
382
498
  version: @api_version || '1.0',
383
499
  }
384
- @api_infos[:description] = @api_description if @api_description
500
+ @api_infos[:description] = @api_description.strip || '' if @api_description.present?
385
501
  @api_infos[:termsOfService] = @api_tos if @api_tos
386
502
  @api_infos[:contact] = @api_contact if @api_contact[:name]
387
503
  @api_infos[:license] = @api_license if @api_license[:name]
@@ -7,139 +7,38 @@ module RSpec
7
7
  module Api
8
8
  # Helper methods
9
9
  class Utils
10
- ##
11
- # Gets a value in a hash by specifying a dotted path
12
- #
13
- # @param hash [Hash] The hash to search in
14
- # @param path [String] The dotted path
15
- #
16
- # @return [*] The value or nil
17
- def self.deep_get(hash, path)
18
- path.split('.').inject(hash) do |sub_hash, key|
19
- return nil unless sub_hash.is_a?(Hash) && sub_hash.key?(key.to_sym)
20
-
21
- sub_hash[key.to_sym] # rubocop:disable Lint/UnmodifiedReduceAccumulator
10
+ class << self
11
+ ##
12
+ # Sets a value at given dotted path in a hash
13
+ #
14
+ # @param hash [Hash] The target hash
15
+ # @param path [Array] List of keys to access value
16
+ # @param value [*] Value to set
17
+ #
18
+ # @return [Hash] The modified hash
19
+ def deep_set(hash, path, value)
20
+ raise 'path should be an array' unless path.is_a? Array
21
+
22
+ return value if path.count.zero?
23
+
24
+ current_key = path.shift.to_s.to_sym
25
+ hash[current_key] = {} unless hash[current_key].is_a?(Hash)
26
+ hash[current_key] = deep_set(hash[current_key], path, value)
27
+
28
+ hash
22
29
  end
23
- end
24
-
25
- ##
26
- # Sets a value at given dotted path in a hash
27
- #
28
- # @param hash [Hash] The target hash
29
- # @param path [String] Dotted path
30
- # @param value [*] Value to set
31
- #
32
- # @return [Hash] The modified hash
33
- def self.deep_set(hash, path, value)
34
- path = path.split('.') unless path.is_a? Array
35
-
36
- return value if path.count.zero?
37
-
38
- current_key = path.shift.to_sym
39
- hash[current_key] = {} unless hash[current_key].is_a?(Hash)
40
- hash[current_key] = deep_set(hash[current_key], path, value)
41
-
42
- hash
43
- end
44
-
45
- ##
46
- # Checks if a value is of the given parameter type
47
- #
48
- # @param type [Symbol] Type to compare to
49
- # @param value [*] Value to test
50
- #
51
- # @return [Boolean] True when the value corresponds to the given type
52
- def self.check_value_type(type, value)
53
- return true if type == :boolean && (value.is_a?(TrueClass) || value.is_a?(FalseClass))
54
- return true if type == :array && value.is_a?(Array)
55
-
56
- raise "Unknown type #{type}" unless PARAM_TYPES.key? type
57
-
58
- value.is_a? PARAM_TYPES[type][:class]
59
- end
60
-
61
- ##
62
- # Validates an object keys and values types
63
- #
64
- # @param actual [Hash] Hash to compare
65
- # @param expected [Hash] Structure to compare
66
- #
67
- # @return [Boolean] True when the object matches the structure
68
- def self.validate_object_structure(actual, expected)
69
- # Check keys
70
- return false unless same_keys? actual, expected
71
-
72
- expected.each_key do |key|
73
- next unless expected[key][:required]
74
-
75
- expected_type = expected[key][:type]
76
- expected_attributes = expected[key][:attributes]
77
30
 
78
- # Type
79
- return false unless check_value_type expected_type, actual[key.to_s]
31
+ ##
32
+ # Returns a hash from an object
33
+ #
34
+ # @param value [Hash,Class] A hash or something with a "body" (as responses object in tests)
35
+ #
36
+ # @return [Hash]
37
+ def hash_from_response(value)
38
+ return JSON.parse(value.body) if value.respond_to? :body
80
39
 
81
- # Deep object ?
82
- return false unless validate_deep_object expected_type, expected_attributes, actual[key.to_s]
40
+ value
83
41
  end
84
-
85
- true
86
- end
87
-
88
- ##
89
- # Validates an array or hash against a definition
90
- #
91
- # @param expected_type [:object, :array] The expected type
92
- # @param expected_attributes [Hash] Attributes configuration
93
- # @param actual [Array, Hash] Value to check
94
- #
95
- # @return [Boolean] True when `actual` is of the expected definition
96
- def self.validate_deep_object(expected_type, expected_attributes, actual)
97
- if %i[object array].include?(expected_type) && expected_attributes.is_a?(Hash)
98
- case expected_type
99
- when :object
100
- return false unless validate_object_structure actual, expected_attributes
101
- when :array
102
- return false unless validate_deep_object_array actual, expected_attributes
103
- end
104
- end
105
-
106
- true
107
- end
108
-
109
- ##
110
- # Validates each entry of an array
111
- #
112
- # @param array [Array] The array to check
113
- # @param expected_attributes [Hash] Attributes configuration
114
- #
115
- # @return [Boolean] True when all values matches the attribute configuration
116
- def self.validate_deep_object_array(array, expected_attributes)
117
- array.each do |array_entry|
118
- if expected_attributes[:type]
119
- return false unless check_value_type expected_attributes[:type], array_entry
120
- else
121
- return false unless validate_object_structure array_entry, expected_attributes
122
- end
123
- end
124
-
125
- true
126
- end
127
-
128
- ##
129
- # Checks if a hash have the required keys in it
130
- #
131
- # @param actual [Hash] The hash
132
- # @param expected [Hash] Attributes definitions
133
- #
134
- # @return [Boolean] True when the object is valid
135
- def self.same_keys?(actual, expected)
136
- optional = expected.reject { |_key, value| value[:required] }.keys
137
- actual.symbolize_keys.keys.sort - optional == expected.keys.sort - optional
138
- end
139
-
140
- def self.check_attribute_type(type, except: [])
141
- keys = PARAM_TYPES.keys.reject { |key| except.include? key }
142
- keys.include?(type)
143
42
  end
144
43
  end
145
44
  end