rspec-rails-api 0.3.1 → 0.4.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.
@@ -27,6 +27,13 @@ module RSpec
27
27
  @api_license = {}
28
28
  end
29
29
 
30
+ ##
31
+ # Merges example context definition with the renderer data
32
+ #
33
+ # @param context [Hash] Metadata hash
34
+ # @param dump_metadata [Boolean] Saves the raw metadata in `tmp/rra_metadata.yaml` for debugging
35
+ #
36
+ # @return [void
30
37
  def merge_context(context, dump_metadata: false)
31
38
  @metadata[:resources].deep_merge! context[:resources]
32
39
  @metadata[:entities].deep_merge! context[:entities]
@@ -35,36 +42,64 @@ module RSpec
35
42
  File.write ::Rails.root.join('tmp', 'rra_metadata.yaml'), context.to_yaml if dump_metadata
36
43
  end
37
44
 
45
+ ##
46
+ # Write OpenAPI files
47
+ #
48
+ # @param path [String, nil] Where to save the files. Defaults to `/tmp/rspec_api_rails.*` when unset
49
+ # @param only [[Symbol]] Formats to save the file to. Allowed values are `:yaml` and `:json`
50
+ #
51
+ # @return [void]
38
52
  def write_files(path = nil, only: %i[yaml json])
39
- content = prepare_metadata
40
53
  path ||= ::Rails.root.join('tmp', 'rspec_api_rails')
41
54
 
55
+ file_types = %i[yaml json]
56
+
42
57
  only.each do |type|
43
- next unless %i[yaml json].include? type
58
+ next unless file_types.include? type
44
59
 
45
- File.write "#{path}.#{type}", content.send("to_#{type}")
60
+ File.write "#{path}.#{type}", prepare_metadata.send("to_#{type}")
46
61
  end
47
62
  end
48
63
 
64
+ ##
65
+ # Extracts metadata from context to generate an OpenAPI structure
66
+ #
67
+ # @return [Hash] The OpenAPI structure
49
68
  def prepare_metadata
50
- # Example: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore-expanded.yaml
51
69
  extract_metadatas
52
- {
70
+ # Example: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore-expanded.yaml
71
+ hash = {
53
72
  openapi: '3.0.0',
54
73
  info: @api_infos,
55
74
  servers: @api_servers,
56
75
  paths: @api_paths,
57
76
  components: @api_components,
58
77
  tags: @api_tags,
59
- }.deep_stringify_keys
78
+ }
79
+ JSON.parse(JSON.pretty_generate(hash))
60
80
  end
61
81
 
82
+ ##
83
+ # Sets the contact field
84
+ #
85
+ # @param name [String, nil] Contact name
86
+ # @param email [String, nil] Contact Email
87
+ # @param url [String, nil] Contact URL
88
+ #
89
+ # @return [void]
62
90
  def api_contact=(name: nil, email: nil, url: nil)
63
91
  @api_contact[:name] = name if name
64
92
  @api_contact[:email] = email if email
65
93
  @api_contact[:url] = url if url
66
94
  end
67
95
 
96
+ ##
97
+ # Sets the license field
98
+ #
99
+ # @param name [String, nil] License name
100
+ # @param url [String, nil] License URL
101
+ #
102
+ # @return [void]
68
103
  def api_license=(name: nil, url: nil)
69
104
  @api_license[:name] = name if name
70
105
  @api_license[:url] = url if url
@@ -72,14 +107,26 @@ module RSpec
72
107
 
73
108
  private
74
109
 
110
+ ##
111
+ # Extracts metadata for rendering
112
+ #
113
+ # @return [void]
75
114
  def extract_metadatas
76
115
  extract_from_resources
77
116
  api_infos
78
117
  api_servers
79
118
  end
80
119
 
81
- def extract_from_resources
120
+ ##
121
+ # Extracts metadata from resources for rendering
122
+ #
123
+ # @return [void]
124
+ def extract_from_resources # rubocop:disable Metrics/MethodLength
125
+ @api_components[:schemas] ||= {}
82
126
  @metadata[:resources].each do |resource_key, resource|
127
+ resource[:entities].each do |name, entity|
128
+ @api_components[:schemas][name] = process_entity(entity)
129
+ end
83
130
  @api_tags.push(
84
131
  name: resource_key.to_s,
85
132
  description: resource[:description]
@@ -88,14 +135,23 @@ module RSpec
88
135
  end
89
136
  end
90
137
 
138
+ ##
139
+ # Processes a resource from metadata
140
+ #
141
+ # @param resource [Symbol, nil] Resource name
142
+ # @param resource_config [Hash, nil] Resource declaration
143
+ #
144
+ #
145
+ # @return [void]
91
146
  def process_resource(resource: nil, resource_config: nil) # rubocop:disable Metrics/MethodLength
147
+ http_verbs = %i[get post put patch delete]
92
148
  resource_config[:paths].each do |path_key, path|
93
149
  url = path_with_params path_key.to_s
94
150
  actions = {}
95
151
  parameters = path.key?(:path_params) ? process_path_params(path[:path_params]) : []
96
152
 
97
153
  path[:actions].each_key do |action|
98
- next unless %i[get post put patch delete].include? action
154
+ next unless http_verbs.include? action
99
155
 
100
156
  actions[action] = process_action resource: resource,
101
157
  path: path_key,
@@ -108,6 +164,10 @@ module RSpec
108
164
  end
109
165
  end
110
166
 
167
+ ##
168
+ # Processes path parameters for rendering
169
+ #
170
+ # @param params [Hash] Path parameters
111
171
  def process_path_params(params)
112
172
  parameters = []
113
173
  params.each do |name, param|
@@ -117,7 +177,40 @@ module RSpec
117
177
  parameters
118
178
  end
119
179
 
120
- def process_path_param(name, param) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
180
+ def process_entity(entity) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
181
+ schema = {
182
+ properties: {},
183
+ }
184
+ required = []
185
+ entity.fields.each do |name, field|
186
+ property = {
187
+ description: field.description,
188
+ type: PARAM_TYPES[field.type][:type],
189
+ }
190
+ property[:format] = PARAM_TYPES[field.type][:format] if PARAM_TYPES[field.type][:format]
191
+ schema[:properties][name] = property
192
+ # Primitives support
193
+ if PRIMITIVES.include? field.attributes
194
+ property[:items] =
195
+ { type: field.attributes.to_s.split('_').last.to_sym }
196
+ end
197
+ required.push name unless field.required == false
198
+ end
199
+
200
+ schema[:required] = required unless required.size.zero?
201
+
202
+ schema
203
+ end
204
+
205
+ ##
206
+ # Processes a path parameter from metadata
207
+ #
208
+ # @param name [Symbol, nil] Parameter name
209
+ # @param param [Hash, nil] Parameter declaration
210
+ #
211
+ #
212
+ # @return [void]
213
+ def process_path_param(name, param) # rubocop:disable Metrics/MethodLength
121
214
  parameter = {
122
215
  name: name.to_s,
123
216
  description: param[:description],
@@ -133,18 +226,30 @@ module RSpec
133
226
  parameter
134
227
  end
135
228
 
229
+ ##
230
+ # Processes an action from metadata
231
+ #
232
+ # @param resource [Symbol, nil] Target resource
233
+ # @param path [Symbol, nil] Target path
234
+ # @param path_config [Hash, nil] Path configuraton
235
+ # @param action_config [Symbol, nil] Target action
236
+ # @param parameters [Array, nil] Path parameters
237
+ #
238
+ # @return [void]
239
+ #
240
+ # FIXME: Rename "action_config" to "action"
241
+ # FIXME: Rename "parameters" to "path_parameters"
136
242
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
137
243
  def process_action(resource: nil, path: nil, path_config: nil, action_config: nil, parameters: nil)
138
244
  responses = {}
139
245
  request_body = nil
140
246
 
141
- if %i[post put patch].include? action_config
142
- if path_config[:actions][action_config][:params].keys.count.positive?
143
- schema = path_config[:actions][action_config][:params]
144
- schema_ref = escape_operation_id("#{action_config}_#{path}")
145
- examples = process_examples(path_config[:actions][action_config][:statuses])
146
- request_body = process_request_body schema: schema, ref: schema_ref, examples: examples
147
- end
247
+ if %i[post put
248
+ patch].include?(action_config) && path_config[:actions][action_config][:params].keys.count.positive?
249
+ schema = path_config[:actions][action_config][:params]
250
+ schema_ref = escape_operation_id("#{action_config}_#{path}")
251
+ examples = process_examples(path_config[:actions][action_config][:statuses])
252
+ request_body = process_request_body schema: schema, ref: schema_ref, examples: examples
148
253
  end
149
254
 
150
255
  path_config[:actions][action_config][:statuses].each do |status_key, status|
@@ -152,10 +257,11 @@ module RSpec
152
257
  responses[status_key] = process_response status: status_key, status_config: status, content: content
153
258
  end
154
259
 
155
- description = path_config[:actions][action_config][:description]
156
- action = {
157
- description: description,
158
- operationId: "#{resource} #{description}".downcase.gsub(/[^\w]/, '_'),
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]/, '_'),
159
265
  parameters: parameters,
160
266
  responses: responses,
161
267
  tags: [resource.to_s],
@@ -167,6 +273,14 @@ module RSpec
167
273
  end
168
274
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
169
275
 
276
+ ##
277
+ # Processes a request body from metadata
278
+ #
279
+ # @param schema [Hash] Schema
280
+ # @param ref [String] Reference
281
+ # @param examples [Hash] Example
282
+ #
283
+ # @return [void]
170
284
  def process_request_body(schema: nil, ref: nil, examples: {})
171
285
  Utils.deep_set @api_components, "schemas.#{ref}", schema
172
286
  {
@@ -181,22 +295,48 @@ module RSpec
181
295
  }
182
296
  end
183
297
 
298
+ ##
299
+ # Process a response from metadata
300
+ #
301
+ # @param status [Symbol] Status code
302
+ # @param status_config [Hash] Configuration for status code
303
+ # @param content [String] Response content
304
+ #
305
+ # @return [void]
184
306
  def process_response(status: nil, status_config: nil, content: nil)
185
- response = {
186
- description: status_config[:description],
187
- }
307
+ response = { description: status_config[:description] }
188
308
 
189
309
  return response if status.to_s == '204' && content # No content
190
310
 
191
311
  response[:content] = {
192
312
  'application/json': {
193
- examples: { default: { value: JSON.pretty_generate(JSON.parse(content)) } },
313
+ schema: response_schema(status_config[:expectations]),
314
+ examples: { default: { value: JSON.parse(content) } },
194
315
  },
195
316
  }
196
317
 
197
318
  response
198
319
  end
199
320
 
321
+ def response_schema(expectations)
322
+ if expectations[:many]
323
+ items = if PRIMITIVES.include?(expectations[:many])
324
+ { type: expectations[:many].to_s.split('_').last }
325
+ else
326
+ { '$ref' => "#/components/schemas/#{expectations[:many]}" }
327
+ end
328
+ { type: 'array', items: items }
329
+ elsif expectations[:one]
330
+ { '$ref' => "#/components/schemas/#{expectations[:one]}" }
331
+ end
332
+ end
333
+
334
+ ##
335
+ # Processes examples from statuses
336
+ #
337
+ # @param statuses [Hash]
338
+ #
339
+ # @return [Hash] Request examples
200
340
  def process_examples(statuses)
201
341
  request_examples = {}
202
342
 
@@ -210,17 +350,33 @@ module RSpec
210
350
  request_examples
211
351
  end
212
352
 
353
+ ##
354
+ # Converts path with params like ":id" to their OpenAPI representation
355
+ #
356
+ # @param string [String] The original path
357
+ #
358
+ # @return [String] OpenAPI path representation
213
359
  def path_with_params(string)
214
360
  string.gsub(/(?::(\w*))/) do |e|
215
361
  "{#{e.sub(':', '')}}"
216
362
  end
217
363
  end
218
364
 
365
+ ##
366
+ # Converts a string to a snake_cased string to use as operationId
367
+ #
368
+ # @param string [String] Original string
369
+ #
370
+ # @return [String] Snake_cased string
219
371
  def escape_operation_id(string)
220
372
  string.downcase.gsub(/[^\w]+/, '_')
221
373
  end
222
374
 
223
- def api_infos # rubocop:disable Metrics/CyclomaticComplexity
375
+ ##
376
+ # Fills the API general information sections
377
+ #
378
+ # @return [void]
379
+ def api_infos
224
380
  @api_infos = {
225
381
  title: @api_title || 'Some sample app',
226
382
  version: @api_version || '1.0',
@@ -233,6 +389,10 @@ module RSpec
233
389
  @api_infos
234
390
  end
235
391
 
392
+ ##
393
+ # Fills the API servers section
394
+ #
395
+ # @return [void]
236
396
  def api_servers
237
397
  @api_servers || [
238
398
  { url: 'http://api.example.com' },
@@ -7,14 +7,29 @@ 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
10
17
  def self.deep_get(hash, path)
11
18
  path.split('.').inject(hash) do |sub_hash, key|
12
19
  return nil unless sub_hash.is_a?(Hash) && sub_hash.key?(key.to_sym)
13
20
 
14
- sub_hash[key.to_sym]
21
+ sub_hash[key.to_sym] # rubocop:disable Lint/UnmodifiedReduceAccumulator
15
22
  end
16
23
  end
17
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
18
33
  def self.deep_set(hash, path, value)
19
34
  path = path.split('.') unless path.is_a? Array
20
35
 
@@ -27,7 +42,14 @@ module RSpec
27
42
  hash
28
43
  end
29
44
 
30
- def self.check_value_type(type, value) # rubocop:disable Metrics/CyclomaticComplexity
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)
31
53
  return true if type == :boolean && (value.is_a?(TrueClass) || value.is_a?(FalseClass))
32
54
  return true if type == :array && value.is_a?(Array)
33
55
 
@@ -36,6 +58,13 @@ module RSpec
36
58
  value.is_a? PARAM_TYPES[type][:class]
37
59
  end
38
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
39
68
  def self.validate_object_structure(actual, expected)
40
69
  # Check keys
41
70
  return false unless same_keys? actual, expected
@@ -56,23 +85,53 @@ module RSpec
56
85
  true
57
86
  end
58
87
 
59
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
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
60
96
  def self.validate_deep_object(expected_type, expected_attributes, actual)
61
97
  if %i[object array].include?(expected_type) && expected_attributes.is_a?(Hash)
62
98
  case expected_type
63
99
  when :object
64
100
  return false unless validate_object_structure actual, expected_attributes
65
101
  when :array
66
- actual.each do |array_entry|
67
- return false unless validate_object_structure array_entry, expected_attributes
68
- end
102
+ return false unless validate_deep_object_array actual, expected_attributes
69
103
  end
70
104
  end
71
105
 
72
106
  true
73
107
  end
74
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
75
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
76
135
  def self.same_keys?(actual, expected)
77
136
  optional = expected.reject { |_key, value| value[:required] }.keys
78
137
  actual.symbolize_keys.keys.sort - optional == expected.keys.sort - optional
@@ -3,7 +3,7 @@
3
3
  module RSpec
4
4
  module Rails
5
5
  module Api
6
- VERSION = '0.3.1'
6
+ VERSION = '0.4.0'
7
7
  end
8
8
  end
9
9
  end
@@ -15,6 +15,8 @@ module RSpec
15
15
  class Error < StandardError
16
16
  end
17
17
 
18
+ ##
19
+ # OpenAPI types, format and Ruby class correspondence
18
20
  PARAM_TYPES = {
19
21
  int32: { type: 'integer', format: 'int32', class: Integer },
20
22
  int64: { type: 'integer', format: 'int64', class: Integer },
@@ -32,6 +34,12 @@ module RSpec
32
34
  array: { type: 'array', format: nil, class: Array },
33
35
  object: { type: 'object', format: nil, class: Hash },
34
36
  }.freeze
37
+
38
+ EXCLUDED_PRIMITIVES = %i[array object].freeze
39
+
40
+ PRIMITIVES = PARAM_TYPES.keys
41
+ .reject { |key| EXCLUDED_PRIMITIVES.include? key }
42
+ .map { |key| "type_#{key}".to_sym }
35
43
  end
36
44
  end
37
45
  end
@@ -18,9 +18,10 @@ Gem::Specification.new do |spec|
18
18
  spec.homepage = 'https://gitlab.com/experimentslabs/rspec-rails-api'
19
19
  spec.license = 'MIT'
20
20
  spec.metadata = {
21
- 'source_code_uri' => 'https://gitlab.com/experimentslabs/rspec-rails-api',
22
- 'bug_tracker_uri' => 'https://gitlab.com/experimentslabs/rspec-rails-api/issues',
23
- 'changelog_uri' => 'https://gitlab.com/experimentslabs/rspec-rails-api/blob/master/CHANGELOG.md',
21
+ 'source_code_uri' => 'https://gitlab.com/experimentslabs/rspec-rails-api',
22
+ 'bug_tracker_uri' => 'https://gitlab.com/experimentslabs/rspec-rails-api/issues',
23
+ 'changelog_uri' => 'https://gitlab.com/experimentslabs/rspec-rails-api/blob/master/CHANGELOG.md',
24
+ 'rubygems_mfa_required' => 'true',
24
25
  }
25
26
 
26
27
  # Specify which files should be added to the gem when it is released.
@@ -35,11 +36,12 @@ Gem::Specification.new do |spec|
35
36
  spec.required_ruby_version = '>= 2.5.0'
36
37
 
37
38
  spec.add_development_dependency 'activesupport', '~> 6.0'
38
- spec.add_development_dependency 'bundler', '~> 1.17'
39
+ spec.add_development_dependency 'bundler'
39
40
  spec.add_development_dependency 'byebug'
40
41
  spec.add_development_dependency 'rake', '~> 10.0'
41
42
  spec.add_development_dependency 'rspec', '~> 3.0'
42
43
  spec.add_development_dependency 'rubocop'
43
44
  spec.add_development_dependency 'rubocop-performance'
44
45
  spec.add_development_dependency 'simplecov'
46
+ spec.add_development_dependency 'yard'
45
47
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-rails-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manuel Tancoigne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-04-20 00:00:00.000000000 Z
11
+ date: 2021-12-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -28,16 +28,16 @@ dependencies:
28
28
  name: bundler
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '1.17'
33
+ version: '0'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '1.17'
40
+ version: '0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: byebug
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - ">="
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: yard
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
125
139
  description: |
126
140
  Create acceptance tests to check the Rails API responses and generate
127
141
  documentation from it.
@@ -162,6 +176,7 @@ metadata:
162
176
  source_code_uri: https://gitlab.com/experimentslabs/rspec-rails-api
163
177
  bug_tracker_uri: https://gitlab.com/experimentslabs/rspec-rails-api/issues
164
178
  changelog_uri: https://gitlab.com/experimentslabs/rspec-rails-api/blob/master/CHANGELOG.md
179
+ rubygems_mfa_required: 'true'
165
180
  post_install_message:
166
181
  rdoc_options: []
167
182
  require_paths: