rspec-rails-api 0.1.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.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.gitlab-ci.yml +36 -0
- data/.rspec +3 -0
- data/.rubocop.yml +29 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +457 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/examples/commented.rb +167 -0
- data/lib/rspec/rails/api/dsl/example.rb +97 -0
- data/lib/rspec/rails/api/dsl/example_group.rb +90 -0
- data/lib/rspec/rails/api/entity_config.rb +44 -0
- data/lib/rspec/rails/api/field_config.rb +52 -0
- data/lib/rspec/rails/api/matchers.rb +40 -0
- data/lib/rspec/rails/api/metadata.rb +178 -0
- data/lib/rspec/rails/api/open_api_renderer.rb +215 -0
- data/lib/rspec/rails/api/utils.rb +88 -0
- data/lib/rspec/rails/api/version.rb +9 -0
- data/lib/rspec_rails_api.rb +37 -0
- data/rspec-rails-api.gemspec +37 -0
- metadata +168 -0
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'rspec_rails_api'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require 'irb'
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,167 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'acceptance_helper'
|
4
|
+
|
5
|
+
RSpec.describe 'Categories', type: :acceptance do
|
6
|
+
# Needed to initialize documentation for this type of request.
|
7
|
+
# It should come before all the other calls (it will let the metadata
|
8
|
+
# class know which resource we're dealing with)
|
9
|
+
resource 'Categories', 'Manage categories in a group'
|
10
|
+
|
11
|
+
# Define an entity that can be returned by an URL.
|
12
|
+
entity :category,
|
13
|
+
id: { type: :integer, description: 'The id' },
|
14
|
+
name: { type: :string, description: 'The name' },
|
15
|
+
group_id: { type: :number, description: 'Concerned group' },
|
16
|
+
created_at: { type: :datetime, description: 'Creation date' },
|
17
|
+
updated_at: { type: :datetime, description: 'Modification date' },
|
18
|
+
url: { type: :string, description: 'URL to this category' }
|
19
|
+
|
20
|
+
entity :error,
|
21
|
+
error: { type: :string, name: 'The error' }
|
22
|
+
|
23
|
+
entity :form_error,
|
24
|
+
errors: {
|
25
|
+
type: :object, description: 'Form errors', attributes: {
|
26
|
+
name: { type: :array, description: 'Name errors' },
|
27
|
+
group: { type: :array, description: 'Group errors' },
|
28
|
+
}
|
29
|
+
}
|
30
|
+
|
31
|
+
# Some vars to use in the tests
|
32
|
+
let(:current_user) { FactoryBot.create :user_active }
|
33
|
+
|
34
|
+
let(:group) { current_user.groups.first }
|
35
|
+
let(:group_id) { group.id }
|
36
|
+
let(:category) { FactoryBot.create :category, user: current_user, group: group }
|
37
|
+
|
38
|
+
# Declare a path accessed with a given method
|
39
|
+
# Their values can be declared with a "let(:var){ value }" statement,
|
40
|
+
# or passed to "visit" method (see "for_code 404")
|
41
|
+
on_get '/api/groups/:group_id/categories', 'Categories defined in the given group' do
|
42
|
+
# Declare path parameters for documentation
|
43
|
+
path_params group_id: { type: :integer, description: 'Target group identifier' }
|
44
|
+
|
45
|
+
# Expectations for the given HTTP status
|
46
|
+
for_code 200, 'Success' do |example|
|
47
|
+
FactoryBot.create_list :category, 2, user: current_user, group: group
|
48
|
+
|
49
|
+
# This method is not built-in; check: ../README.md
|
50
|
+
sign_in current_user
|
51
|
+
|
52
|
+
# Actually visit the path. It will expect the given code and a
|
53
|
+
# content of type "application/JSON" (except for 204: no content
|
54
|
+
# statuses)
|
55
|
+
visit example
|
56
|
+
|
57
|
+
# Custom matcher: have_many will expect a body with an array of
|
58
|
+
# the given entity. All entries in the response will have its keys
|
59
|
+
# checked (not the content type).
|
60
|
+
# The `defined` method will get the correct entity for comparison
|
61
|
+
expect(response).to have_many defined :category
|
62
|
+
|
63
|
+
# Other expectations
|
64
|
+
expect(JSON.parse(response.body).count).to eq 2
|
65
|
+
end
|
66
|
+
|
67
|
+
for_code 404, 'Not found (not owned)' do |example|
|
68
|
+
sign_in current_user
|
69
|
+
|
70
|
+
group_id = FactoryBot.create(:user_active).groups.first.id
|
71
|
+
|
72
|
+
visit example, path_params: { group_id: group_id }
|
73
|
+
expect(response).to have_one defined :error
|
74
|
+
end
|
75
|
+
|
76
|
+
for_code 401, 'Not authorized' do |example|
|
77
|
+
visit example
|
78
|
+
expect(response).to have_one defined :error
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
on_get '/api/groups/:group_id/categories/:id', 'Get category' do
|
83
|
+
path_params group_id: { type: :integer, description: 'Target group identifier' },
|
84
|
+
id: { type: :integer, description: 'Category id' }
|
85
|
+
|
86
|
+
let(:id) { category.id }
|
87
|
+
|
88
|
+
for_code 200, 'Success' do |example|
|
89
|
+
sign_in current_user
|
90
|
+
|
91
|
+
visit example
|
92
|
+
expect(response).to have_one defined :category
|
93
|
+
end
|
94
|
+
|
95
|
+
for_code 401, 'Not authorized' do |example|
|
96
|
+
visit example
|
97
|
+
expect(response).to have_one defined :error
|
98
|
+
end
|
99
|
+
|
100
|
+
for_code 404, 'Not found' do |example|
|
101
|
+
sign_in current_user
|
102
|
+
|
103
|
+
visit example, path_params: { id: 0 }
|
104
|
+
expect(response).to have_one defined :error
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
on_post '/api/groups/:group_id/categories', 'Creates a category' do
|
109
|
+
path_params group_id: { type: :integer, description: 'Target group identifier' }
|
110
|
+
|
111
|
+
request_params category: {
|
112
|
+
type: :object, required: true, attributes: {
|
113
|
+
name: { type: :string, required: true, description: 'The name' },
|
114
|
+
}
|
115
|
+
}
|
116
|
+
|
117
|
+
let(:id) { category.id }
|
118
|
+
|
119
|
+
for_code 201, 'Success' do |example|
|
120
|
+
sign_in current_user
|
121
|
+
|
122
|
+
visit example, payload: { category: { name: 'New category' } }
|
123
|
+
expect(response).to have_one defined :category
|
124
|
+
end
|
125
|
+
|
126
|
+
for_code 422, 'Form error' do |example|
|
127
|
+
sign_in current_user
|
128
|
+
|
129
|
+
visit example, payload: { category: {} }
|
130
|
+
expect(response).to have_one defined :form_error
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
on_put '/api/groups/:group_id/categories/:id', 'Updates a category' do
|
135
|
+
path_params group_id: { type: :integer, description: 'Target group identifier' },
|
136
|
+
id: { type: :integer, description: 'Category id' }
|
137
|
+
|
138
|
+
let(:id) { category.id }
|
139
|
+
|
140
|
+
request_params category: {
|
141
|
+
type: :object, required: true, description: 'New category attributes', attributes: {
|
142
|
+
name: { type: :string, required: false, description: 'The new name' },
|
143
|
+
}
|
144
|
+
}
|
145
|
+
|
146
|
+
for_code 200, 'Success' do |example|
|
147
|
+
sign_in current_user
|
148
|
+
|
149
|
+
visit example, payload: { category: { name: 'New name' } }
|
150
|
+
expect(response).to have_one defined :category
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
on_delete '/api/groups/:group_id/categories/:id', 'Deletes a category' do
|
155
|
+
path_params group_id: { type: :integer, description: 'Target group identifier' },
|
156
|
+
id: { type: :integer, description: 'Category id' }
|
157
|
+
|
158
|
+
let(:id) { category.id }
|
159
|
+
|
160
|
+
for_code 204, 'Success' do |example|
|
161
|
+
sign_in current_user
|
162
|
+
|
163
|
+
visit example
|
164
|
+
# Not checking content for 204 responses
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RSpec
|
4
|
+
module Rails
|
5
|
+
module Api
|
6
|
+
module DSL
|
7
|
+
# These methods will be available in examples (i.e.: 'for_code')
|
8
|
+
module Example
|
9
|
+
def visit(example, path_params: {}, payload: {}) # rubocop:disable Metrics/AbcSize
|
10
|
+
raise 'Missing context. Call visit with for_code context.' unless example
|
11
|
+
|
12
|
+
status_code = prepare_status_code example.class.description
|
13
|
+
request_params = prepare_request_params example.class.parent.description, path_params, payload
|
14
|
+
|
15
|
+
send(request_params[:action],
|
16
|
+
request_params[:url],
|
17
|
+
params: request_params[:params].to_json,
|
18
|
+
headers: request_params[:headers])
|
19
|
+
|
20
|
+
check_response(response, status_code)
|
21
|
+
|
22
|
+
set_request_example example.class.metadata[:rrad], request_params, status_code, response.body
|
23
|
+
end
|
24
|
+
|
25
|
+
def defined(entity)
|
26
|
+
current_resource = self.class.metadata[:rrad].current_resource
|
27
|
+
raise '@current_resource is unset' unless current_resource
|
28
|
+
|
29
|
+
entities = self.class.metadata[:rrad].resources[current_resource][:entities]
|
30
|
+
|
31
|
+
out = entities[entity]
|
32
|
+
raise "Unkown entity '#{entity}' in resource '#{current_resource}'" unless out
|
33
|
+
|
34
|
+
out.expand_with(entities)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def check_response(response, expected_code)
|
40
|
+
expect(response.status).to eq expected_code
|
41
|
+
expect(response.headers['Content-Type']).to eq 'application/json; charset=utf-8' if expected_code != 204
|
42
|
+
end
|
43
|
+
|
44
|
+
def set_request_example(rrad_metadata, request_params, status_code = nil, response = nil)
|
45
|
+
rrad_metadata.add_request_example(url: request_params[:example_url],
|
46
|
+
action: request_params[:action],
|
47
|
+
status_code: status_code,
|
48
|
+
response: response,
|
49
|
+
path_params: request_params[:path_params],
|
50
|
+
params: request_params[:params])
|
51
|
+
end
|
52
|
+
|
53
|
+
def prepare_request_params(description, request_params = {}, payload = {})
|
54
|
+
example_params = description.split ' '
|
55
|
+
|
56
|
+
{
|
57
|
+
action: example_params[0].downcase,
|
58
|
+
url: prepare_request_url(example_params[1], request_params),
|
59
|
+
example_url: example_params[1],
|
60
|
+
params: payload,
|
61
|
+
headers: prepare_request_headers,
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
# Replace path params by values
|
66
|
+
def prepare_request_url(url, request_params)
|
67
|
+
url.gsub(/(?::(\w*))/) do |e|
|
68
|
+
symbol = e.sub(':', '').to_sym
|
69
|
+
if request_params.key?(symbol)
|
70
|
+
request_params[symbol]
|
71
|
+
elsif respond_to?(symbol)
|
72
|
+
send(symbol)
|
73
|
+
else
|
74
|
+
puts "! Define #{symbol} (let(:#{symbol}){ value }) or pass it to 'visit'"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def prepare_request_headers
|
80
|
+
{
|
81
|
+
'Accept' => 'application/json',
|
82
|
+
'Content-Type' => 'application/json',
|
83
|
+
}
|
84
|
+
end
|
85
|
+
|
86
|
+
def prepare_status_code(description)
|
87
|
+
code_match = /-> (\d+) - .*/.match description
|
88
|
+
|
89
|
+
raise 'Please provide a numerical code for the "for_code" block' unless code_match
|
90
|
+
|
91
|
+
code_match[1].to_i
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rspec/rails/api/metadata'
|
4
|
+
|
5
|
+
module RSpec
|
6
|
+
module Rails
|
7
|
+
module Api
|
8
|
+
module DSL
|
9
|
+
# All these methods will be available in example groups
|
10
|
+
# (anything but 'it', 'example', 'for_code')
|
11
|
+
module ExampleGroup
|
12
|
+
# First method to be called in a spec file
|
13
|
+
# as it will initialize the metadatas.
|
14
|
+
def resource(name, description = '')
|
15
|
+
metadata[:rrad] ||= Metadata.new
|
16
|
+
metadata[:rrad].add_resource name, description
|
17
|
+
end
|
18
|
+
|
19
|
+
# Used to describe an entity
|
20
|
+
def entity(type, fields)
|
21
|
+
metadata[:rrad].add_entity type, fields
|
22
|
+
end
|
23
|
+
|
24
|
+
# Used to describe query parameters
|
25
|
+
def path_params(fields)
|
26
|
+
metadata[:rrad].add_path_params fields
|
27
|
+
end
|
28
|
+
|
29
|
+
def request_params(fields)
|
30
|
+
metadata[:rrad].add_request_params fields
|
31
|
+
end
|
32
|
+
|
33
|
+
def on_get(url, description = nil, &block)
|
34
|
+
on_action(:get, url, description, &block)
|
35
|
+
end
|
36
|
+
|
37
|
+
def on_post(url, description = nil, &block)
|
38
|
+
on_action(:post, url, description, &block)
|
39
|
+
end
|
40
|
+
|
41
|
+
def on_put(url, description = nil, &block)
|
42
|
+
on_action(:put, url, description, &block)
|
43
|
+
end
|
44
|
+
|
45
|
+
def on_patch(url, description = nil, &block)
|
46
|
+
on_action(:patch, url, description, &block)
|
47
|
+
end
|
48
|
+
|
49
|
+
def on_delete(url, description = nil, &block)
|
50
|
+
on_action(:delete, url, description, &block)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Currently fill metadatas with the action
|
54
|
+
def on_action(action, url, description, &block)
|
55
|
+
metadata[:rrad].add_action(action, url, description)
|
56
|
+
|
57
|
+
describe("#{action.upcase} #{url}", &block)
|
58
|
+
end
|
59
|
+
|
60
|
+
def for_code(status_code, description, doc_only: false, &block)
|
61
|
+
metadata[:rrad].add_status_code(status_code, description)
|
62
|
+
|
63
|
+
describe "-> #{status_code} - #{description}" do
|
64
|
+
if (!ENV['DOC_ONLY'] || ENV['DOC_ONLY'] == 'false' || !doc_only) && block
|
65
|
+
example 'Test and create documentation', caller: block.send(:caller) do
|
66
|
+
instance_eval(&block) if block_given?
|
67
|
+
end
|
68
|
+
else
|
69
|
+
document_only status_code
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def document_only(status_code)
|
77
|
+
example 'Create documentation' do |example|
|
78
|
+
parent_example = example.example_group
|
79
|
+
request_params = prepare_request_params parent_example.parent.description
|
80
|
+
|
81
|
+
set_request_example parent_example.metadata[:rrad], request_params, status_code
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
RSpec::Core::ExampleGroup.extend(RSpec::Rails::Api::DSL::ExampleGroup)
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rspec/rails/api/field_config'
|
4
|
+
|
5
|
+
module RSpec
|
6
|
+
module Rails
|
7
|
+
module Api
|
8
|
+
# Represents an entity configuration.
|
9
|
+
# Basically, entities configuration only have a collection of fields
|
10
|
+
# and a method to serialize them for comparison with actual content
|
11
|
+
class EntityConfig
|
12
|
+
attr_accessor :fields
|
13
|
+
|
14
|
+
def initialize(fields)
|
15
|
+
@fields = {}
|
16
|
+
fields.each_key do |name|
|
17
|
+
@fields[name] = FieldConfig.new fields[name]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_h
|
22
|
+
out = {}
|
23
|
+
@fields.each_key do |key|
|
24
|
+
out[key] = @fields[key].to_h
|
25
|
+
end
|
26
|
+
out
|
27
|
+
end
|
28
|
+
|
29
|
+
def expand_with(entities)
|
30
|
+
hash = to_h
|
31
|
+
hash.each_pair do |field, config|
|
32
|
+
next unless %i[array object].include? config[:type]
|
33
|
+
|
34
|
+
attributes = config[:attributes]
|
35
|
+
next unless attributes.is_a? Symbol
|
36
|
+
raise "Entity #{attributes} not found for entity completion." unless entities[attributes]
|
37
|
+
|
38
|
+
hash[field][:attributes] = entities[attributes].expand_with(entities)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rspec/rails/api/entity_config'
|
4
|
+
require 'rspec/rails/api/utils'
|
5
|
+
|
6
|
+
module RSpec
|
7
|
+
module Rails
|
8
|
+
module Api
|
9
|
+
# Represents an entity field configuration.
|
10
|
+
# A field have some options and a method to serialize itself.
|
11
|
+
class FieldConfig
|
12
|
+
attr_accessor :required, :type, :attributes, :description
|
13
|
+
|
14
|
+
def initialize(type:, required: true, description:, attributes: nil, of: nil)
|
15
|
+
@required = required
|
16
|
+
@description = description
|
17
|
+
raise "Field type not allowed: '#{type}'" unless Utils.check_attribute_type(type)
|
18
|
+
|
19
|
+
define_attributes attributes if type == :object
|
20
|
+
define_attributes of if type == :array
|
21
|
+
|
22
|
+
@type = type
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_h
|
26
|
+
out = { required: @required, type: @type }
|
27
|
+
out[:description] = @description unless @description.nil?
|
28
|
+
|
29
|
+
if %i[object array].include?(@type) && @attributes
|
30
|
+
out[:attributes] = if @attributes.is_a? EntityConfig
|
31
|
+
@attributes.to_h
|
32
|
+
elsif attributes.is_a? Symbol
|
33
|
+
@attributes
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
out
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def define_attributes(attributes)
|
43
|
+
@attributes = if attributes.is_a? Hash
|
44
|
+
@attributes = EntityConfig.new attributes
|
45
|
+
elsif attributes.is_a? Symbol
|
46
|
+
attributes
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|