rspec-rails-api 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|