rspec-rails-api 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 232e1ea8b43dd1e3b5258f0029d28a54d886156634e87d4a6854a3543752fb0f
4
- data.tar.gz: 71828ab75d08ff900cbe9f018af219bc79b98231118712d127e35c274690d7ad
3
+ metadata.gz: aa6db3329e09e4356d205cf49f100e11132a3e8a8fcb681ef8ede5e22ba470dd
4
+ data.tar.gz: e25b15faecc41214f92bb089f8fc9e28702f9c5ad2072daf4014251d30f23291
5
5
  SHA512:
6
- metadata.gz: dbb37718158f52e0a2056bbe9b8ccde44782cafe915cc146f05333532484b10ae8260e7abbd3f571e657c778b8d8ac37c731adfa452d3e94af253dcf6849aede
7
- data.tar.gz: 731f623490f7e15b3f0acb8851955835083249b60e62f722ab9870c6658d3e690ed0b6aca8ac4edcdc3f6b1c892517511937d190459b8020fcb2b3c2ef99047a
6
+ metadata.gz: 2c947cf296de131ece545c7a21ed093d79de1e1fd5f57af53c58985eab815c781ce4dcbba7f7269c679dbf8155d4eabf0ae5035caa70bbd88090650630ed8e11
7
+ data.tar.gz: b0c4d8bef1c715b0ea369b5f7a659e8fa69344735af2a7735a51a1cb755c3fc3b642597c47a133be9568c0361d8565725ed803d2b0dd530050d1e286795ee911
data/CHANGELOG.md CHANGED
@@ -1,22 +1,74 @@
1
1
  # Change Log
2
2
  All notable changes to this project will be documented in this file.
3
3
 
4
- ## Not released
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
6
 
6
- ## 0.5.0 - 2023-01-02
7
+ <!--
8
+ Quick remainder of the possible sections:
9
+ -----------------------------------------
10
+ Added, for new features.
11
+ Changed, for changes in existing functionality.
12
+ Deprecated, for soon-to-be removed features.
13
+ Removed, for now removed features.
14
+ Fixed, for any bug fixes.
15
+ Security, in case of vulnerabilities.
16
+ Maintenance, in case of rework, dependencies change
17
+ -->
18
+
19
+ ## [Unreleased]
20
+
21
+ ## [0.6.0] - 2023-07-17
22
+
23
+ ### Added
24
+
25
+ - Declaring the same resource multiple times is now possible
26
+ - Add a simple support for security schemes
27
+ - Add support for global entities declarations. Keep things DRYer :)
28
+ - `test_response_of`: add `ignore_content_type` flag to ignore response's content type
29
+ - `test_response_of`: add `ignore_response` flag to ignore tests on response
30
+ - Renderer: Add simple support for redactable content. It will replace content in responses by something else. For now,
31
+ sub-entities are not redacted.
32
+ - Support for `file` type. When declaring a field with the `file` type, it will change the request content-type to
33
+ `multipart/form-data` automatically.
34
+
35
+ ### Changed
36
+
37
+ - Generated JSON files will always be prettified to ease changes reviews when they are versioned
38
+ - When an unexpected 422 error happens, also display its content in error message
39
+ - [BREAKING] It is now impossible to declare the same entity twice. To remediate, rename your declared entities and/or
40
+ use global declarations. Check [README](./README.md) for example.
41
+ - Renderer: Use response data as-is when it's not valid JSON
42
+
43
+ ### Fixed
44
+
45
+ - Strip generated descriptions and summaries
46
+ - Primitives are no more referenced in schemas for "expect_one" types
47
+ - Compare type `:float` against `Numeric` class
48
+ - Entities: Raise error when using `:array` type with `attributes` property and `:object` type with `of` property
49
+ - Rendering of sub-references now use the correct reference
50
+ - Metadata: Add missing type on array parameters
51
+ - Don't transform parameters into JSON when making `get` requests
52
+ - Operation IDs don't use summaries for uniqueness
53
+ - Downcase response content type before comparison
54
+ - Type format is now added on request parameters if applicable
55
+
56
+ ## [0.5.0] - 2023-01-02
57
+
58
+ ### Changed
7
59
 
8
60
  - Improved error messages
9
61
  - Improved usage of primitive types: use `:string` instead of `:type_string`, and more generally, remove the `type_`
10
62
  prefix
63
+
64
+ ### Fixed
65
+ -
11
66
  - Fixed an error when an object is defined with an attribute named `type`.
12
67
 
13
- ## 0.4.0 - 2021-12-19
68
+ ## [0.4.0] - 2021-12-19
69
+
70
+ ### Added
14
71
 
15
- - All parameters attributes are considered required unless specified
16
- - Fix object `attributes` key in spec and documentation.
17
- When defining object attributes, documentation and tests used `properties` key
18
- while the code was waiting for an `attributes` key. The later makes more sense
19
- so the spec and documentation were fixed.
20
72
  - Check for arrays of primitives is now a thing:
21
73
  ```rb
22
74
  # In entities, when attribute is an array of primitives
@@ -28,15 +80,19 @@ All notable changes to this project will be documented in this file.
28
80
  #...
29
81
  end
30
82
  ```
31
- - RSpec metadata is now stored in `rra` instead of `rrad` (the gem's first name
32
- was RSpec Rails API Doc at the time). Update RSpec configuration accordingly.
33
83
  - OpenApi:
34
84
  - Responses now includes the schema
35
85
  - `on_xxx` methods second parameter is now used as summary instead of description.
36
86
  Description can be defined on the third parameter.
87
+
88
+ ### Changed
89
+
90
+ - All parameters attributes are considered required unless specified
91
+ - RSpec metadata is now stored in `rra` instead of `rrad` (the gem's first name
92
+ was RSpec Rails API Doc at the time). Update RSpec configuration accordingly.
37
93
  - DSL changes:
38
94
  - `visit` is renamed to `test_response_of`
39
- - Support for `doc_only` is removed
95
+ - Support for `doc_only` is removed
40
96
  - Response expectations _should_ now be declared with `for_code`, and should be
41
97
  removed from example bodies:
42
98
  ```rb
@@ -52,44 +108,71 @@ All notable changes to this project will be documented in this file.
52
108
  end
53
109
  ```
54
110
 
111
+ ### Fixed
112
+
113
+ - Fix object `attributes` key in spec and documentation.
114
+ When defining object attributes, documentation and tests used `properties` key
115
+ while the code was waiting for an `attributes` key. The later makes more sense
116
+ so the spec and documentation were fixed.
117
+
118
+ ## [0.3.4] - 2021-10-20
119
+
120
+ ### Added
55
121
 
56
- ## 0.3.4 - 2021-10-20
57
122
  - Add the "required" attribute in parameters
58
123
 
59
- ## 0.3.3 - 2021-06-02
124
+ ## [0.3.3] - 2021-06-02
125
+
126
+ ### Fixed
127
+
60
128
  - Fix correct types on request parameters
61
129
 
62
- ## 0.3.2 - 2021-03-09
63
- - Fix YAML rendering (ruby objects were sometimes rendered in documentation)
130
+ ## [0.3.2] - 2021-03-09
131
+
132
+ ### Changed
133
+
64
134
  - Render examples results as YAML/JSON objects instead of text blocks.
65
135
 
66
- ## 0.3.1 - 2020-04-09
136
+ ### Fixed
137
+
138
+ - Fix YAML rendering (ruby objects were sometimes rendered in documentation)
139
+
140
+ ## [0.3.1] - 2020-04-09
141
+
142
+ ### Added
143
+
67
144
  - Add support for "test only" examples, allowing to write examples without documentation.
68
145
 
69
- ## 0.3.0 - 2019-12-26
146
+ ## [0.3.0] - 2019-12-26
70
147
 
71
148
  ### Changed
149
+
72
150
  - Rails 6 support, deprecated methods from Rails 5 are not supported. Use version `0.2.3`
73
151
  of this gem if your application is still on 5.
74
152
 
75
153
  ## 0.2.3 - 2019-12-04
76
154
 
77
- ### Improved
155
+ ### Added
78
156
 
79
157
  - Generated Swagger file now use the payloads of POST/PATCH/PUT requests.
80
- - Minimum Ruby version is now specified: Ruby 2.3.3.
81
158
 
82
- ## 0.2.1/0.2.2 - 2019-11-03
159
+ ### Changed
160
+
161
+ - Minimum Ruby version is now specified: Ruby 2.3.3.
83
162
 
84
- _Version 0.2.1 was released and yanked by mistake. Version 0.2.2 is the exact
85
- same one, with a version bump_
163
+ ## [0.2.2] - 2019-11-03
86
164
 
87
165
  ### Changed
88
166
 
89
167
  - `for_code` method now have its `description` optional. If none is provided,
90
168
  the description will be set from the status code.
91
169
 
92
- ## 0.2.0 - 2019-11-02
170
+ ## [0.2.1] - 2019-11-03 [YANKED]
171
+
172
+ _Version 0.2.1 was released and yanked by mistake. Version 0.2.2 is the exact
173
+ same one, with a version bump_
174
+
175
+ ## [0.2.0] - 2019-11-02
93
176
 
94
177
  ### Added
95
178
 
@@ -108,35 +191,35 @@ of the fixtures.
108
191
  the existing calls accordingly. To use params defined with `parameters`, use the
109
192
  `defined` option: `request_params defined: :common_form_params`
110
193
 
111
- ## 0.1.5 - 2019-10-31
194
+ ## [0.1.5] - 2019-10-31
112
195
 
113
196
  ### Fixed
114
197
 
115
198
  - Fixed issue with POST/PUT/DELETE requests with no `request_params`
116
199
  - Improved documentation (integration with Devise, typos and mistakes)
117
200
 
118
- ## 0.1.4 - 2019-10-24
201
+ ## [0.1.4] - 2019-10-24
119
202
 
120
203
  ### Added
121
204
 
122
205
  - Added support for arrays of objects in request parameters
123
206
 
124
- ## 0.1.3 - 2019-10-23
207
+ ## [0.1.3] - 2019-10-23
125
208
 
126
209
  ### Added
127
210
 
128
211
  - Added ability to document API descriptions, servers, etc... from the RSpec helper files
129
212
 
130
- ## 0.1.2 - 2019-10-22
213
+ ## [0.1.2] - 2019-10-22
131
214
 
132
215
  ### Added
133
216
 
134
217
  - Added `item` property for arrays descriptions
135
218
 
136
- ## 0.1.1 - 2019-10-22
219
+ ## [0.1.1] - 2019-10-22
137
220
 
138
221
  - Added support for custom headers in request examples (useful for `visit` method)
139
222
 
140
- ## 0.1.0 - 2019-10-21
223
+ ## [0.1.0] - 2019-10-21
141
224
 
142
225
  Initial release
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspec-rails-api (0.5.0)
4
+ rspec-rails-api (0.6.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -72,7 +72,101 @@ end
72
72
 
73
73
  ## Configuration
74
74
 
75
- **TODO: This section is incomplete and the gem has no generator yet**
75
+ **TODO: This section is incomplete and the gem has no generator yet.**
76
+
77
+ Here is an example configuration:
78
+
79
+ ```rb
80
+ # spec/rails_helper.rb
81
+ require 'spec/support/rspec_rails_api'
82
+ ```
83
+
84
+ ```rb
85
+ # spec/support/acceptance_entities.rb
86
+
87
+ # This file contains common object definitions
88
+ {
89
+ error: {
90
+ error: { type: :string, description: "Error message" }
91
+ },
92
+ form_error: {
93
+ title: { type: :array, required: false, description: "Title errors", of: :string }
94
+ },
95
+ }.each do |name, attributes|
96
+ RSpec::Rails::Api::Metadata.add_entity name, attributes
97
+ end
98
+ ```
99
+
100
+ ```rb
101
+ # spec/support/rspec_rails_api.rb
102
+ require 'rspec_rails_api'
103
+ require 'support/acceptance_entities'
104
+
105
+ # Include DSL in RSpec
106
+ RSpec.configure do |config|
107
+ config.include RSpec::Rails::Api::DSL::Example
108
+ end
109
+
110
+ # Initialize and configure the renderer
111
+ renderer = RSpec::Rails::Api::OpenApiRenderer.new
112
+ # Server URL for quick reference
113
+ server_url = 'https://example.com'
114
+
115
+ # Options here should be present for a valid OpenAPI file
116
+ renderer.api_title = 'MyProject API'
117
+ renderer.api_version = '1'
118
+
119
+ # Options below are optional
120
+ #
121
+ # API description. Markdown supported
122
+ # renderer.api_description = 'Manage data on MyProject'
123
+ #
124
+ # List of servers, to live-test the documentation
125
+ # renderer.api_servers = [{ url: server_url }, { url: 'http://localhost:3000' }]
126
+ #
127
+ # Link to the API terms of service, if any
128
+ # renderer.api_tos = 'http://example.com/tos.html'
129
+ #
130
+ # Contact information
131
+ # renderer.api_contact = { name: 'Admin', email: 'admin@example.com', url: 'http://example.com/contact' }
132
+ #
133
+ # API license information
134
+ # renderer.api_license = { name: 'Apache', url: 'https://opensource.org/licenses/Apache-2.0' }
135
+ #
136
+ # Possible security schemes
137
+ # renderer.add_security_scheme :pkce_code_grant, 'PKCE code grant',
138
+ # type: 'oauth2',
139
+ # flows: {
140
+ # implicit: {
141
+ # authorizationUrl: "#{server_url}/oauth/authorize",
142
+ # scopes: { read: 'will read data on your behalf', write: 'will write data on your behalf' }
143
+ # }
144
+ # }
145
+ # renderer.add_security_scheme :bearer, 'Bearer token',
146
+ # type: 'http',
147
+ # scheme: 'bearer'
148
+ #
149
+ # Declare keys whose values should be filtered in responses.
150
+ # renderer.redact_responses entity_name: { key: 'REDACTED' },
151
+ # other_entity: { other_key: ['REDACTED'] }
152
+
153
+
154
+ # We need to merge each context metadata so we can reference to them to build the final file
155
+ RSpec.configuration.after(:context, type: :acceptance) do |context|
156
+ renderer.merge_context context.class.metadata[:rra].to_h
157
+ # During development of rspec_rails_api, you may want to dump raw metadata to a file
158
+ renderer.merge_context context.class.metadata[:rra].to_h, dump_metadata: true
159
+ end
160
+
161
+ # Skip this block if you don't need the OpenAPI documentation file and only have your responses tested
162
+ RSpec.configuration.after(:suite) do
163
+ renderer.write_files Rails.root.join('public/swagger') # Write both YAML and prettified JSON files
164
+ # or
165
+ renderer.write_files Rails.root.join('public/swagger'), only: [:json] # Prettified JSON only
166
+ # or
167
+ renderer.write_files Rails.root.join('public/swagger'), only: [:yaml] # YAML only
168
+ end
169
+ ```
76
170
 
77
171
  ### Integration with Devise
78
172
 
@@ -124,7 +218,7 @@ end
124
218
  # In examples
125
219
  #...
126
220
  for_code 200, 'Success' do |url|
127
- sing_in #...
221
+ sign_in #...
128
222
  test_response_of url
129
223
 
130
224
  #...
@@ -149,7 +243,7 @@ The idea is to have a simple DSL, and declare things like:
149
243
  **spec/acceptance/users_spec.rb**
150
244
 
151
245
  ```ruby
152
- require 'acceptance_helper'
246
+ require 'rails_helper'
153
247
 
154
248
  RSpec.describe 'Users', type: :acceptance do
155
249
  resource 'Users', 'Manage users'
@@ -186,6 +280,78 @@ RSpec.describe 'Users', type: :acceptance do
186
280
  end
187
281
  ```
188
282
 
283
+ ### Entity declarations
284
+
285
+ You can declare entities locally (in every spec files), but sometimes you will need to use/reference the same entity
286
+ in multiple spec files (e.g.: an error message). In that case, you can create _global_ entities in separate files, and they
287
+ will be picked-up when needed.
288
+
289
+ Example of a local entity:
290
+
291
+ ```rb
292
+ # spec/acceptance/api/users_acceptance_spec.rb
293
+ require 'rails_helper'
294
+
295
+ RSpec.describe 'Users', type: :acceptance do
296
+ resource 'Users', 'Manage users'
297
+
298
+ # This is a local entity
299
+ entity :user,
300
+ id: { type: :integer, description: 'The id' },
301
+ email: { type: :string, description: 'The name' },
302
+ role: { type: :string, description: 'The name' },
303
+ created_at: { type: :datetime, description: 'Creation date' },
304
+ updated_at: { type: :datetime, description: 'Modification date' },
305
+ url: { type: :string, description: 'URL to this category' }
306
+
307
+ on_get '/api/users/', 'Users list' do
308
+ for_code 200, 'Success response', expect_many: :user do |url|
309
+ test_response_of url
310
+ end
311
+ end
312
+
313
+ #...
314
+ end
315
+ ```
316
+
317
+ Defining global entities:
318
+
319
+ ```rb
320
+ # spec/support/entities/user.rb
321
+ # This file should be required at some point in the "rails_helper" or "acceptance_helper"
322
+
323
+ require 'rspec/rails/api/metadata'
324
+
325
+ RSpec::Rails::Api::Metadata.add_entity :user,
326
+ id: { type: :integer, description: 'The id' },
327
+ email: { type: :string, description: 'The name' },
328
+ role: { type: :string, description: 'The name' },
329
+ created_at: { type: :datetime, description: 'Creation date' },
330
+ updated_at: { type: :datetime, description: 'Modification date' },
331
+ url: { type: :string, description: 'URL to this category' }
332
+
333
+ ```
334
+
335
+ Organization of the global entities declaration is up to you.
336
+
337
+ ```rb
338
+ # spec/acceptance/api/users_acceptance_spec.rb
339
+ require 'rails_helper'
340
+
341
+ RSpec.describe 'Users', type: :acceptance do
342
+ resource 'Users', 'Manage users'
343
+
344
+ on_get '/api/users/', 'Users list' do
345
+ # "user" will use the global user entity
346
+ for_code 200, 'Success response', expect_many: :user do |url|
347
+ test_response_of url
348
+ end
349
+ end
350
+
351
+ #...
352
+ end
353
+ ```
354
+
189
355
  ### DSL
190
356
 
191
357
  #### Example groups
@@ -197,6 +363,23 @@ Starts a resource description.
197
363
  - It must be called before any other documentation calls.
198
364
  - It should be in the first `describe block`
199
365
 
366
+ A resource may be completed across multiple spec files:
367
+
368
+ ```rb
369
+ # an_acceptance_spec.rb
370
+ RSpec.describe 'Something', type: :acceptance do
371
+ resource 'User', 'Manage users'
372
+ end
373
+
374
+ # another_acceptance_spec.rb
375
+ RSpec.describe 'Something else', type: :acceptance do
376
+ resource 'User', 'Another description'
377
+ end
378
+
379
+ ```
380
+
381
+ The first evaluated `resource` statement will be used as description; all the tests in both files will complete it.
382
+
200
383
  ##### `entity(type, fields)`
201
384
 
202
385
  Describes an entity for the documentation. The type is only a reference,
@@ -423,11 +606,30 @@ Once again, you have to pass an argument to the block if you use
423
606
  # ...
424
607
  ```
425
608
 
609
+ ##### `requires_security(scheme_references)`
610
+
611
+ Specifies the valid security schemes to use for this request. Security schemes are declared at the renderer level
612
+ (see [the configuration example](#Configuration)).
613
+
614
+ ```rb
615
+ # Given a previously :basic scheme
616
+
617
+ # ...
618
+ on_get '/some/path' do
619
+ require_security :basic, :implicit
620
+
621
+ for_code 200 do |url|
622
+ #...
623
+ end
624
+ end
625
+ # ...
626
+ ```
627
+
426
628
  #### Examples
427
629
 
428
630
  Example methods are available in `for_code` blocks
429
631
 
430
- ##### `test_response_of(example, path_params: {}, payload: {}, headers: {})`
632
+ ##### `test_response_of(example, path_params: {}, payload: {}, headers: {}, ignore_content_type: false)`
431
633
 
432
634
  Visits the described URL and:
433
635
 
@@ -441,6 +643,7 @@ Visits the described URL and:
441
643
  - `payload`: a hash of values to send. Ignored for GET and DELETE
442
644
  requests
443
645
  - `headers`: a hash of custom headers.
646
+ - `ignore_content_type`: whether to ignore response's content-type. By default, checks for a JSON response
444
647
 
445
648
  ```ruby
446
649
  for_code 200, 'Success' do |url|
@@ -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,7 +39,10 @@ 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
  #
@@ -49,10 +53,10 @@ module RSpec
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
- raise "Unknown entity '#{entity}' in resource '#{current_resource}'" unless entities.key? entity.to_sym
56
+ definition = RSpec::Rails::Api::Metadata.entities[entity.to_sym]
57
+ raise "Entity '#{entity}' was never defined (globally or in '#{current_resource}')" unless definition
54
58
 
55
- entities[entity.to_sym].expand_with(entities)
59
+ definition.expand_with(RSpec::Rails::Api::Metadata.entities)
56
60
  end
57
61
 
58
62
  ##
@@ -60,9 +64,21 @@ module RSpec
60
64
  #
61
65
  # @param response [ActionDispatch::TestResponse] The response
62
66
  # @param expected_code [Number] Code to test for
63
- def check_response(response, expected_code) # rubocop:disable Metrics/AbcSize
64
- expect(response.status).to eq expected_code
65
- 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
66
82
  expectations = rra_current_example[:expectations]
67
83
  expect(response).to have_many defined(expectations[:many]) if expectations[:many]
68
84
  expect(response).to have_one defined(expectations[:one]) if expectations[:one]
@@ -98,9 +114,12 @@ module RSpec
98
114
  # @return [Hash] Options for the request
99
115
  def prepare_request_params(description, request_params = {}, payload = {}, request_headers = {})
100
116
  example_params = description.split
117
+ verb = example_params[0].downcase
118
+
119
+ payload = payload.to_json if verb != 'get'
101
120
 
102
121
  {
103
- action: example_params[0].downcase,
122
+ action: verb,
104
123
  url: prepare_request_url(example_params[1], request_params),
105
124
  example_url: example_params[1],
106
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
  #
@@ -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
 
@@ -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
+
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
@@ -10,11 +10,10 @@ module RSpec
10
10
  module Api
11
11
  # Handles contexts and examples metadata.
12
12
  class Metadata # rubocop:disable Metrics/ClassLength
13
- 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
14
14
 
15
15
  def initialize
16
16
  @resources = {}
17
- @entities = {}
18
17
  @parameters = {}
19
18
  # Only used when building metadata during RSpec boot
20
19
  @current_resource = nil
@@ -23,6 +22,33 @@ module RSpec
23
22
  @current_code = nil
24
23
  end
25
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
+
26
52
  ##
27
53
  # Adds a resource to metadata
28
54
  #
@@ -31,24 +57,10 @@ module RSpec
31
57
  #
32
58
  # @return [void]
33
59
  def add_resource(name, description)
34
- @resources[name.to_sym] = { description: description, paths: {}, entities: {} }
60
+ @resources[name.to_sym] ||= { description: description, paths: {} }
35
61
  @current_resource = name.to_sym
36
62
  end
37
63
 
38
- ##
39
- # Adds an entity definition
40
- #
41
- # @param name [Symbol] Entity name
42
- # @param fields [Hash] Fields definitions
43
- #
44
- #
45
- # @return [void]
46
- def add_entity(name, fields)
47
- Utils.deep_set(@resources,
48
- [@current_resource, 'entities', name],
49
- EntityConfig.new(fields))
50
- end
51
-
52
64
  ##
53
65
  # Adds a parameter definition
54
66
  #
@@ -61,6 +73,7 @@ module RSpec
61
73
 
62
74
  fields.each_value do |field|
63
75
  field[:required] = true unless field[:required] == false
76
+ field[:schema] = { type: field[:of] } if field[:type] == :array && PRIMITIVES.include?(field[:of])
64
77
  end
65
78
  @parameters[name] = fields
66
79
  end
@@ -114,6 +127,20 @@ module RSpec
114
127
  params)
115
128
  end
116
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
+
117
144
  ##
118
145
  # Adds an action and sets `@current_url` and `@current_method`
119
146
  #
@@ -294,7 +321,7 @@ module RSpec
294
321
  # @param field [Hash] Parameter definition
295
322
  #
296
323
  # @return [Hash] Completed parameter
297
- def fill_request_param(field)
324
+ def fill_request_param(field) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
298
325
  if field[:type] == :object && field[:attributes]
299
326
  organize_params field[:attributes]
300
327
  else
@@ -302,6 +329,7 @@ module RSpec
302
329
  type: PARAM_TYPES[field[:type]][:type],
303
330
  description: field[:description] || nil,
304
331
  }
332
+ properties[:format] = PARAM_TYPES[field[:type]][:format] if PARAM_TYPES[field[:type]][:format]
305
333
 
306
334
  properties[:items] = organize_params field[:of] if field[:type] == :array && field[:of]
307
335
  properties
@@ -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
  ##
@@ -36,7 +56,6 @@ module RSpec
36
56
  # @return [void
37
57
  def merge_context(context, dump_metadata: false)
38
58
  @metadata[:resources].deep_merge! context.respond_to?(:resources) ? context.resources : context[:resources]
39
- @metadata[:entities].deep_merge! context.respond_to?(:entities) ? context.entities : context[:entities]
40
59
 
41
60
  # Save context for debug and fixtures
42
61
  File.write ::Rails.root.join('tmp', 'rra_metadata.yaml'), @metadata.to_yaml if dump_metadata
@@ -54,11 +73,16 @@ module RSpec
54
73
 
55
74
  path ||= ::Rails.root.join('tmp', 'rspec_api_rails')
56
75
 
76
+ metadata = prepare_metadata
77
+
57
78
  file_types = %i[yaml json]
58
79
  only.each do |type|
59
80
  next unless file_types.include? type
60
81
 
61
- 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
62
86
  end
63
87
  end
64
88
 
@@ -77,7 +101,7 @@ module RSpec
77
101
  components: @api_components,
78
102
  tags: @api_tags,
79
103
  }
80
- JSON.parse(JSON.pretty_generate(hash))
104
+ JSON.parse(hash.to_json)
81
105
  end
82
106
 
83
107
  ##
@@ -125,24 +149,33 @@ module RSpec
125
149
  #
126
150
  # @return [void]
127
151
  def extract_metadata
128
- extract_from_resources
152
+ extract_security
129
153
  api_infos
130
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
131
167
  end
132
168
 
133
169
  ##
134
170
  # Extracts metadata from resources for rendering
135
171
  #
136
172
  # @return [void]
137
- def extract_from_resources # rubocop:disable Metrics/MethodLength
173
+ def extract_from_resources
138
174
  @api_components[:schemas] ||= {}
139
175
  @metadata[:resources].each do |resource_key, resource|
140
- resource[:entities].each do |name, entity|
141
- @api_components[:schemas][name] = process_entity(entity)
142
- end
143
176
  @api_tags.push(
144
177
  name: resource_key.to_s,
145
- description: resource[:description]
178
+ description: resource[:description].presence&.strip || ''
146
179
  )
147
180
  process_resource resource: resource_key, resource_config: resource
148
181
  end
@@ -190,22 +223,29 @@ module RSpec
190
223
  parameters
191
224
  end
192
225
 
193
- 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
194
227
  schema = {
195
228
  properties: {},
196
229
  }
197
230
  required = []
198
231
  entity.fields.each do |name, field|
199
232
  property = {
200
- description: field.description,
233
+ description: field.description.presence&.strip || '',
201
234
  type: PARAM_TYPES[field.type][:type],
202
235
  }
203
- property[:format] = PARAM_TYPES[field.type][:format] if PARAM_TYPES[field.type][:format]
204
- schema[:properties][name] = property
205
- # Primitives support
206
- property[:items] = { type: field.attributes } if PRIMITIVES.include? field.attributes
236
+ property[:format] = PARAM_TYPES[field.type][:format] if PARAM_TYPES[field.type][:format]
237
+
238
+ if PRIMITIVES.include? field.attributes
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}" } }
244
+ end
207
245
 
208
246
  required.push name unless field.required == false
247
+
248
+ schema[:properties][name] = property
209
249
  end
210
250
 
211
251
  schema[:required] = required unless required.size.zero?
@@ -221,10 +261,10 @@ module RSpec
221
261
  #
222
262
  #
223
263
  # @return [void]
224
- def process_path_param(name, param) # rubocop:disable Metrics/MethodLength
264
+ def process_path_param(name, param) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
225
265
  parameter = {
226
266
  name: name.to_s,
227
- description: param[:description],
267
+ description: param[:description].presence&.strip || '',
228
268
  required: param[:required] || true,
229
269
  in: param[:scope].to_s,
230
270
  schema: {
@@ -250,7 +290,7 @@ module RSpec
250
290
  #
251
291
  # FIXME: Rename "action_config" to "action"
252
292
  # FIXME: Rename "parameters" to "path_parameters"
253
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
293
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
254
294
  def process_action(resource: nil, path: nil, path_config: nil, action_config: nil, parameters: nil)
255
295
  responses = {}
256
296
  request_body = nil
@@ -268,21 +308,31 @@ module RSpec
268
308
  responses[status_key] = process_response status: status_key, status_config: status, content: content
269
309
  end
270
310
 
271
- summary = path_config[:actions][action_config][:summary]
272
- action = {
273
- summary: summary,
274
- description: path_config[:actions][action_config][:description],
275
- 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]/, '_'),
276
315
  parameters: parameters,
277
316
  responses: responses,
278
317
  tags: [resource.to_s],
279
318
  }
280
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
+
281
331
  action[:requestBody] = request_body if request_body
282
332
 
283
333
  action
284
334
  end
285
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
335
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
286
336
 
287
337
  ##
288
338
  # Processes a request body from metadata
@@ -294,11 +344,12 @@ module RSpec
294
344
  # @return [void]
295
345
  def process_request_body(schema: nil, ref: nil, examples: {})
296
346
  Utils.deep_set @api_components, ['schemas', ref], schema
347
+
297
348
  {
298
349
  # description: '',
299
350
  required: true,
300
351
  content: {
301
- 'application/json' => {
352
+ content_type_from_schema(schema) => {
302
353
  schema: { '$ref' => "#/components/schemas/#{ref}" },
303
354
  examples: examples,
304
355
  },
@@ -306,6 +357,23 @@ module RSpec
306
357
  }
307
358
  end
308
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
+
309
377
  ##
310
378
  # Process a response from metadata
311
379
  #
@@ -314,22 +382,45 @@ module RSpec
314
382
  # @param content [String] Response content
315
383
  #
316
384
  # @return [void]
317
- def process_response(status: nil, status_config: nil, content: nil)
318
- 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 || '' }
319
387
 
320
388
  return response if status.to_s == '204' && content # No content
321
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
+
322
413
  response[:content] = {
323
414
  'application/json': {
324
415
  schema: response_schema(status_config[:expectations]),
325
- examples: { default: { value: JSON.parse(content) } },
416
+ examples: { default: { value: data } },
326
417
  },
327
418
  }
328
419
 
329
420
  response
330
421
  end
331
422
 
332
- def response_schema(expectations)
423
+ def response_schema(expectations) # rubocop:disable Metrics/MethodLength
333
424
  if expectations[:many]
334
425
  items = if PRIMITIVES.include?(expectations[:many])
335
426
  { type: expectations[:many] }
@@ -338,7 +429,11 @@ module RSpec
338
429
  end
339
430
  { type: 'array', items: items }
340
431
  elsif expectations[:one]
341
- { '$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
342
437
  end
343
438
  end
344
439
 
@@ -361,6 +456,16 @@ module RSpec
361
456
  request_examples
362
457
  end
363
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
+
364
469
  ##
365
470
  # Converts path with params like ":id" to their OpenAPI representation
366
471
  #
@@ -387,12 +492,12 @@ module RSpec
387
492
  # Fills the API general information sections
388
493
  #
389
494
  # @return [void]
390
- def api_infos
495
+ def api_infos # rubocop:disable Metrics/CyclomaticComplexity
391
496
  @api_infos = {
392
497
  title: @api_title || 'Some sample app',
393
498
  version: @api_version || '1.0',
394
499
  }
395
- @api_infos[:description] = @api_description if @api_description
500
+ @api_infos[:description] = @api_description.strip || '' if @api_description.present?
396
501
  @api_infos[:termsOfService] = @api_tos if @api_tos
397
502
  @api_infos[:contact] = @api_contact if @api_contact[:name]
398
503
  @api_infos[:license] = @api_license if @api_license[:name]
@@ -3,7 +3,7 @@
3
3
  module RSpec
4
4
  module Rails
5
5
  module Api
6
- VERSION = '0.5.0'
6
+ VERSION = '0.6.0'
7
7
  end
8
8
  end
9
9
  end
@@ -30,9 +30,10 @@ module RSpec
30
30
  boolean: { type: 'boolean', format: nil },
31
31
  string: { type: 'string', format: nil, class: String },
32
32
  integer: { type: 'integer', format: nil, class: Integer },
33
- number: { type: 'number', format: nil, class: Float },
33
+ number: { type: 'number', format: nil, class: Numeric },
34
34
  array: { type: 'array', format: nil, class: Array },
35
35
  object: { type: 'object', format: nil, class: Hash },
36
+ file: { type: 'string', format: 'binary' },
36
37
  }.freeze
37
38
 
38
39
  PRIMITIVES = PARAM_TYPES.keys
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.5.0
4
+ version: 0.6.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: 2023-01-02 00:00:00.000000000 Z
11
+ date: 2023-07-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport