rspec-rails-api 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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