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.
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