rspec-rails-api 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: