active_record_api-rest 0.0.2
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/.codeclimate.yml +27 -0
- data/.gitignore +14 -0
- data/.gitlab-ci.yml +70 -0
- data/.rspec +3 -0
- data/.rubocop.yml +1207 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +214 -0
- data/LICENSE.txt +21 -0
- data/README.md +37 -0
- data/Rakefile +8 -0
- data/active_record_api-rest.gemspec +42 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/active_record_api-rest.rb +17 -0
- data/lib/active_record_api/rest/concerns/graceful_errors.rb +17 -0
- data/lib/active_record_api/rest/controller.rb +63 -0
- data/lib/active_record_api/rest/index_controller.rb +84 -0
- data/lib/active_record_api/rest/railtie.rb +15 -0
- data/lib/active_record_api/rest/request_url_generator.rb +29 -0
- data/lib/active_record_api/rest/spec.rb +3 -0
- data/lib/active_record_api/rest/spec/rest_controller_shared_example.rb +213 -0
- data/lib/active_record_api/rest/version.rb +7 -0
- metadata +194 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record_api/rest/version'
|
4
|
+
require 'active_support'
|
5
|
+
require 'action_controller'
|
6
|
+
require 'active_record'
|
7
|
+
require 'cancancan'
|
8
|
+
require 'active_model_serializers'
|
9
|
+
require 'active_attr'
|
10
|
+
|
11
|
+
module ActiveRecordApi
|
12
|
+
module Rest
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
require_relative 'active_record_api/rest/railtie'
|
17
|
+
require_relative 'active_record_api/rest/request_url_generator'
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module ActiveRecordApi
|
2
|
+
module Rest
|
3
|
+
module GracefulErrors
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
rescue_from ActiveRecord::RecordNotFound do |exception|
|
8
|
+
render status: :not_found, json: { base: exception.message }
|
9
|
+
end
|
10
|
+
|
11
|
+
rescue_from CanCan::AccessDenied do |exception|
|
12
|
+
render status: :forbidden, json: { base: "Access denied on #{exception.action} #{exception.subject.inspect}" }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require_relative 'index_controller'
|
2
|
+
|
3
|
+
module ActiveRecordApi
|
4
|
+
module Rest
|
5
|
+
class Controller < IndexController
|
6
|
+
before_action :initialize_model, only: :create
|
7
|
+
before_action :only_valid_params, only: %i[create update]
|
8
|
+
|
9
|
+
def show
|
10
|
+
return unless stale?(last_modified: model.updated_at)
|
11
|
+
render json: model, serializer: serializer
|
12
|
+
end
|
13
|
+
|
14
|
+
def create
|
15
|
+
if model.save
|
16
|
+
redirect_to_model
|
17
|
+
else
|
18
|
+
render json: model.errors, status: :unprocessable_entity
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def update
|
23
|
+
if model.update(resource_params)
|
24
|
+
redirect_to_model
|
25
|
+
else
|
26
|
+
render json: model.errors, status: :unprocessable_entity
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def destroy
|
31
|
+
model.destroy!
|
32
|
+
head :no_content
|
33
|
+
end
|
34
|
+
|
35
|
+
protected
|
36
|
+
|
37
|
+
def redirect_to_model
|
38
|
+
host = RequestUrlGenerator.new(request: request).micro_service_url
|
39
|
+
relative_url = url_for(controller: controller_name, action: 'show', id: model.id, only_path: true)
|
40
|
+
redirect_to "#{host}#{relative_url}", status: :see_other
|
41
|
+
end
|
42
|
+
|
43
|
+
def only_valid_params
|
44
|
+
return unless not_allowed_params.present?
|
45
|
+
render json: { base: "Extra parameters are not allow: #{not_allowed_params.join(', ')}" }, status: :unprocessable_entity
|
46
|
+
end
|
47
|
+
|
48
|
+
def resource_params
|
49
|
+
@resource_params ||= filtered_params.reject do |column_name|
|
50
|
+
column_name == :id
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def not_allowed_params
|
55
|
+
@not_allowed_params ||= ((params.keys.map(&:to_sym) - [:controller, :action, model_klass.name.underscore.to_sym]) - resource_params.keys.map(&:to_sym))
|
56
|
+
end
|
57
|
+
|
58
|
+
def initialize_model
|
59
|
+
instance_variable_set("@#{controller_name.singularize}", model_klass.new(filtered_params))
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require_relative 'concerns/graceful_errors'
|
2
|
+
|
3
|
+
module ActiveRecordApi
|
4
|
+
module Rest
|
5
|
+
class IndexController < ApplicationController
|
6
|
+
include CanCan::ControllerAdditions
|
7
|
+
|
8
|
+
include GracefulErrors
|
9
|
+
|
10
|
+
load_and_authorize_resource
|
11
|
+
before_action :load_models, only: :index
|
12
|
+
|
13
|
+
def index
|
14
|
+
response.headers['x-total'] = @total
|
15
|
+
response.headers['x-link-next'] = next_link
|
16
|
+
render json: models, each_serializer: serializer
|
17
|
+
end
|
18
|
+
|
19
|
+
protected
|
20
|
+
|
21
|
+
def next_link
|
22
|
+
return unless @remaining_count > limit
|
23
|
+
new_params = request.query_parameters.dup
|
24
|
+
new_params['previous_id'] = models.last.id
|
25
|
+
RequestUrlGenerator.new(request: request, new_params: new_params).url
|
26
|
+
end
|
27
|
+
|
28
|
+
def serializer
|
29
|
+
"#{model_klass.name}Serializer".safe_constantize
|
30
|
+
end
|
31
|
+
|
32
|
+
def allowed_params
|
33
|
+
@allowed_params ||= model_klass.column_names.map do |column_name|
|
34
|
+
column_name.to_s.gsub('encrypted_', '')
|
35
|
+
end.map(&:to_sym)
|
36
|
+
end
|
37
|
+
|
38
|
+
def filtered_params
|
39
|
+
@filtered_params ||= params.permit(allowed_params).to_h
|
40
|
+
end
|
41
|
+
|
42
|
+
def model
|
43
|
+
instance_variable_get("@#{controller_name.singularize}")
|
44
|
+
end
|
45
|
+
|
46
|
+
def load_models
|
47
|
+
filter_models
|
48
|
+
@total = models.count
|
49
|
+
page_models
|
50
|
+
end
|
51
|
+
|
52
|
+
def filter_models
|
53
|
+
models models.where(filtered_params)
|
54
|
+
end
|
55
|
+
|
56
|
+
def page_models
|
57
|
+
id_field = model_klass.arel_table[:id]
|
58
|
+
models models.order(:id).where(id_field.gt previous_id)
|
59
|
+
@remaining_count = models.count
|
60
|
+
models models.limit(limit)
|
61
|
+
end
|
62
|
+
|
63
|
+
def models(updated_models = nil)
|
64
|
+
if updated_models.nil?
|
65
|
+
instance_variable_get("@#{controller_name}")
|
66
|
+
else
|
67
|
+
instance_variable_set("@#{controller_name}", updated_models)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def model_klass
|
72
|
+
@model_klass ||= controller_name.classify.constantize
|
73
|
+
end
|
74
|
+
|
75
|
+
def limit
|
76
|
+
@limit ||= params[:limit]&.to_i || 50
|
77
|
+
end
|
78
|
+
|
79
|
+
def previous_id
|
80
|
+
@previous_id ||= params[:previous_id] || 0
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails'
|
4
|
+
|
5
|
+
#:nocov:#
|
6
|
+
module ActiveRecordApi
|
7
|
+
module Rest
|
8
|
+
class Railtie < Rails::Railtie
|
9
|
+
initializer 'load order fix for application controller' do
|
10
|
+
require_relative 'controller'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
#:nocov:#
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module ActiveRecordApi
|
2
|
+
module Rest
|
3
|
+
class RequestUrlGenerator
|
4
|
+
include ActiveAttr::Model
|
5
|
+
|
6
|
+
attribute :request
|
7
|
+
attribute :new_params, default: {}
|
8
|
+
|
9
|
+
delegate :host, :path, to: :request, prefix: true
|
10
|
+
|
11
|
+
def url
|
12
|
+
"#{micro_service_url}#{request_path}?#{new_params.to_param}"
|
13
|
+
end
|
14
|
+
|
15
|
+
def micro_service_url
|
16
|
+
"#{protocol}#{request_host}/api/#{micro_service_name}"
|
17
|
+
end
|
18
|
+
|
19
|
+
def micro_service_name
|
20
|
+
Rails.application.class.parent.to_s.underscore.tr('_', '-')
|
21
|
+
end
|
22
|
+
|
23
|
+
def protocol
|
24
|
+
return 'http://' if Rails.env.development?
|
25
|
+
'https://'
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,213 @@
|
|
1
|
+
shared_examples 'all::rest::actions' do
|
2
|
+
let(:authorize_read) { mock_valid_auth(permission_name(model_klass, 'r'), model.organization_id) }
|
3
|
+
let(:unauthorize_read) { mock_valid_auth(permission_name(model_klass, ''), -1) }
|
4
|
+
let(:authorize_create) { mock_valid_auth(permission_name(model_klass, 'c'), model.organization_id) }
|
5
|
+
let(:unauthorize_create) { mock_valid_auth(permission_name(model_klass, ''), -1) }
|
6
|
+
let(:authorize_update) { mock_valid_auth(permission_name(model_klass, 'u'), model.organization_id) }
|
7
|
+
let(:unauthorize_update) { mock_valid_auth(permission_name(model_klass, ''), -1) }
|
8
|
+
let(:authorize_delete) { mock_valid_auth(permission_name(model_klass, 'd'), model.organization_id) }
|
9
|
+
let(:unauthorize_delete) { mock_valid_auth(permission_name(model_klass, ''), -1) }
|
10
|
+
let(:factory_symbol) { model_klass.to_s.underscore.to_sym }
|
11
|
+
let(:index_non_accessible_data) { create factory_symbol, organization_id: (model.organization_id + 1) }
|
12
|
+
let(:model_array) { [model] + create_list(factory_symbol, 4, organization_id: model.organization_id) }
|
13
|
+
describe 'All rest actions' do
|
14
|
+
let(:serializer) { ActiveModelSerializers::SerializableResource }
|
15
|
+
let(:model_klass) { described_class.controller_name.classify.constantize }
|
16
|
+
let(:model) { create model_klass.to_s.underscore.to_sym }
|
17
|
+
include_examples 'get::show'
|
18
|
+
include_examples 'get::index'
|
19
|
+
include_examples 'put::update'
|
20
|
+
include_examples 'post::create'
|
21
|
+
include_examples 'delete::delete'
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
shared_examples 'get::show' do
|
26
|
+
describe 'GET show' do
|
27
|
+
context 'when authorized' do
|
28
|
+
before(:each) do
|
29
|
+
authorize_read
|
30
|
+
end
|
31
|
+
context 'when valid' do
|
32
|
+
before(:each) do
|
33
|
+
get :show, params: { id: model.to_param }
|
34
|
+
end
|
35
|
+
it { expect(response.status).to eq 200 }
|
36
|
+
it { expect(response.body).to be_json_eql(serializer.new(model).to_json) }
|
37
|
+
end
|
38
|
+
context 'when not found' do
|
39
|
+
before(:each) do
|
40
|
+
get :show, params: { id: -1 }
|
41
|
+
end
|
42
|
+
it { expect(response.status).to eq 404 }
|
43
|
+
it { expect(JSON.parse(response.body)['base']).to include "Couldn't find #{model_klass} with 'id'=-1" }
|
44
|
+
end
|
45
|
+
end
|
46
|
+
context 'when not authorized' do
|
47
|
+
before(:each) do
|
48
|
+
unauthorize_read
|
49
|
+
get :show, params: { id: model.to_param }
|
50
|
+
end
|
51
|
+
it { expect(response.status).to eq 403 }
|
52
|
+
it { expect(JSON.parse(response.body)['base']).to include 'Access denied on show' }
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
shared_examples 'get::index' do
|
58
|
+
describe 'GET index' do
|
59
|
+
context 'when authorized' do
|
60
|
+
before(:each) do
|
61
|
+
authorize_read
|
62
|
+
end
|
63
|
+
context 'when one result' do
|
64
|
+
before(:each) do
|
65
|
+
model
|
66
|
+
get :index
|
67
|
+
end
|
68
|
+
it { expect(response.status).to eq 200 }
|
69
|
+
it { expect(response.headers['x-total']).to eq 1 }
|
70
|
+
it { expect(response.headers['x-link-next']).to be nil }
|
71
|
+
end
|
72
|
+
|
73
|
+
context 'when paged results' do
|
74
|
+
let(:previous_id) { model_klass.limit(1).pluck(:id).first }
|
75
|
+
let(:next_previous_id) { model_klass.where('id > ?', previous_id).limit(1).pluck(:id).last }
|
76
|
+
before(:each) do
|
77
|
+
model_array
|
78
|
+
get :index, params: { previous_id: previous_id, limit: 1 }
|
79
|
+
end
|
80
|
+
it { expect(response.status).to eq 200 }
|
81
|
+
it { expect(response.headers['x-total']).to eq model_array.length }
|
82
|
+
it { expect(response.headers['x-link-next']).to eq "https://test.host/api/#{Rails.application.class.parent.to_s.underscore}#{request.path}?limit=1&previous_id=#{next_previous_id}" }
|
83
|
+
it { expect(response.body).to be_json_eql model_klass.where('id > ?', previous_id).limit(1).map { |o| serializer.new(o) }.to_json }
|
84
|
+
end
|
85
|
+
|
86
|
+
context 'when only access some of the data' do
|
87
|
+
before(:each) do
|
88
|
+
index_non_accessible_data
|
89
|
+
get :index
|
90
|
+
end
|
91
|
+
it { expect(response.status).to eq 200 }
|
92
|
+
it { expect(response.headers['x-total']).to eq 1 }
|
93
|
+
it { expect(response.body).to be_json_eql [serializer.new(model)].to_json }
|
94
|
+
end
|
95
|
+
end
|
96
|
+
context 'when not authorized' do
|
97
|
+
before(:each) do
|
98
|
+
unauthorize_read
|
99
|
+
end
|
100
|
+
before(:each) do
|
101
|
+
get :index
|
102
|
+
end
|
103
|
+
it { expect(response.status).to eq 403 }
|
104
|
+
it { expect(JSON.parse(response.body)['base']).to include 'Access denied on index' }
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
shared_examples 'put::update' do
|
110
|
+
let(:payload) { model.clone.attributes }
|
111
|
+
let(:host) { ActiveRecordApi::Rest::RequestUrlGenerator.new(request: request).micro_service_url }
|
112
|
+
let(:relative_url) { Rails.application.routes.url_helpers.url_for(controller: model_klass.to_s.underscore.pluralize, action: 'show', id: model.id, only_path: true) }
|
113
|
+
describe 'PUT update' do
|
114
|
+
context 'when authorized' do
|
115
|
+
before(:each) do
|
116
|
+
authorize_update
|
117
|
+
end
|
118
|
+
context 'valid' do
|
119
|
+
before(:each) do
|
120
|
+
put :update, params: payload
|
121
|
+
end
|
122
|
+
it { expect(response.status).to eq 303 }
|
123
|
+
it { expect(response).to redirect_to "#{host}#{relative_url}" }
|
124
|
+
end
|
125
|
+
context 'when invalid param values provided' do
|
126
|
+
before(:each) do
|
127
|
+
put :update, params: update_invalid_model.attributes.merge(id: model.id)
|
128
|
+
expect(update_invalid_model.valid?).to be false
|
129
|
+
end
|
130
|
+
it { expect(response.status).to eq(422) }
|
131
|
+
it { expect(response.body).to be_json_eql(update_invalid_model.errors.to_json) }
|
132
|
+
end
|
133
|
+
context 'when invalid param keys provided' do
|
134
|
+
before(:each) do
|
135
|
+
put :update, params: model.attributes.merge('foobars' => 'stuff')
|
136
|
+
end
|
137
|
+
it { expect(response.status).to eq(422) }
|
138
|
+
it { expect(response.body).to be_json_eql({ base: 'Extra parameters are not allow: foobars' }.to_json) }
|
139
|
+
end
|
140
|
+
end
|
141
|
+
context 'when not authorized' do
|
142
|
+
before(:each) do
|
143
|
+
unauthorize_update
|
144
|
+
get :show, params: payload
|
145
|
+
end
|
146
|
+
it { expect(response.status).to eq 403 }
|
147
|
+
it { expect(JSON.parse(response.body)['base']).to include 'Access denied on show' }
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
shared_examples 'post::create' do
|
153
|
+
let(:new_attributes) { model.dup.attributes }
|
154
|
+
let(:model_id) { model.id }
|
155
|
+
let(:host) { ActiveRecordApi::Rest::RequestUrlGenerator.new(request: request).micro_service_url }
|
156
|
+
let(:relative_url) { Rails.application.routes.url_helpers.url_for(controller: model_klass.to_s.underscore.pluralize, action: 'show', id: model_klass.last.id, only_path: true) }
|
157
|
+
describe 'POST create' do
|
158
|
+
context 'when authorized' do
|
159
|
+
before(:each) do
|
160
|
+
authorize_create
|
161
|
+
end
|
162
|
+
context 'when invalid' do
|
163
|
+
before(:each) do
|
164
|
+
expect_any_instance_of(model_klass).to receive(:save).and_return(false)
|
165
|
+
post :create, params: new_attributes
|
166
|
+
end
|
167
|
+
it { expect(response.status).to eq 422 }
|
168
|
+
end
|
169
|
+
context 'when valid' do
|
170
|
+
it 'creates record with attributes' do
|
171
|
+
model.delete
|
172
|
+
expect {
|
173
|
+
post :create, params: new_attributes
|
174
|
+
expect(response).to redirect_to "#{host}#{relative_url}"
|
175
|
+
}.to change(model_klass, :count).by(1)
|
176
|
+
expect(model_klass.last.id).not_to eq model_id
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
context 'when not authorized' do
|
181
|
+
before(:each) do
|
182
|
+
unauthorize_create
|
183
|
+
post :create, params: new_attributes
|
184
|
+
end
|
185
|
+
it { expect(response.status).to eq 403 }
|
186
|
+
it { expect(JSON.parse(response.body)['base']).to include 'Access denied on create' }
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
shared_examples 'delete::delete' do
|
192
|
+
describe 'DELETE delete' do
|
193
|
+
context 'when authorized' do
|
194
|
+
before(:each) do
|
195
|
+
authorize_delete
|
196
|
+
end
|
197
|
+
it 'remove record' do
|
198
|
+
expect {
|
199
|
+
expect_any_instance_of(model_klass).to receive(:destroyed_bus_cleanup_message_publish) if model.respond_to? :destroyed_bus_cleanup_message_publish
|
200
|
+
delete :destroy, params: { id: model.id }
|
201
|
+
}.to change(model_klass, :count).by(-1)
|
202
|
+
end
|
203
|
+
end
|
204
|
+
context 'when not authorized' do
|
205
|
+
before(:each) do
|
206
|
+
unauthorize_delete
|
207
|
+
delete :destroy, params: { id: model.id }
|
208
|
+
end
|
209
|
+
it { expect(response.status).to eq 403 }
|
210
|
+
it { expect(JSON.parse(response.body)['base']).to include 'Access denied on destroy' }
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|