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.
@@ -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