deep_unrest 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/README.md +285 -0
- data/Rakefile +37 -0
- data/app/assets/config/deep_unrest_manifest.js +2 -0
- data/app/assets/javascripts/deep_unrest/application.js +13 -0
- data/app/assets/stylesheets/deep_unrest/application.css +15 -0
- data/app/controllers/deep_unrest/application_controller.rb +38 -0
- data/app/helpers/deep_unrest/application_helper.rb +4 -0
- data/app/jobs/deep_unrest/application_job.rb +4 -0
- data/app/mailers/deep_unrest/application_mailer.rb +6 -0
- data/app/models/deep_unrest/application_record.rb +5 -0
- data/app/views/layouts/deep_unrest/application.html.erb +14 -0
- data/config/routes.rb +3 -0
- data/lib/deep_unrest.rb +338 -0
- data/lib/deep_unrest/authorization/base_strategy.rb +6 -0
- data/lib/deep_unrest/authorization/none_strategy.rb +6 -0
- data/lib/deep_unrest/authorization/pundit_strategy.rb +52 -0
- data/lib/deep_unrest/concerns/null_concern.rb +17 -0
- data/lib/deep_unrest/engine.rb +22 -0
- data/lib/deep_unrest/version.rb +3 -0
- data/lib/tasks/deep_unrest_tasks.rake +4 -0
- metadata +190 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 16cfa1baac8c2a73223981948c63a5573202ea22
|
4
|
+
data.tar.gz: 31f808c464f4b7c10470728d8a8f731afb2deca1
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4592d731550af2ed983b9e520d3794abce76c706b372d95381942325b980a4359495a5b6ce14b281bbd733dbd3513f79f6a9276f1027668ed21377108bd91589
|
7
|
+
data.tar.gz: 420cf01968544730f6f1842986e2c9000dc1204c259d043a11b73e62c31346c81e61ed0b917e84134df94e4caeebd7b8e01a886c4fb5f6afa62eb91b78252175
|
data/README.md
ADDED
@@ -0,0 +1,285 @@
|
|
1
|
+
# Deep UnREST
|
2
|
+
|
3
|
+
[](https://circleci.com/gh/graveflex/deep_unrest)
|
4
|
+
[](https://codeclimate.com/repos/591cc05fbd15c32ce10014b4/coverage)
|
5
|
+
[](https://codeclimate.com/repos/591cc05fbd15c32ce10014b4/feed)
|
6
|
+
[](https://codeclimate.com/repos/591cc05fbd15c32ce10014b4/feed)
|
7
|
+
|
8
|
+
<img src="/docs/img/logo.png" width="100%">
|
9
|
+
|
10
|
+
Perform updates on deeply nested resources as well as bulk operations.
|
11
|
+
|
12
|
+
## Goals
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
Add this line to your application's Gemfile:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
gem 'deep_unrest'
|
19
|
+
```
|
20
|
+
|
21
|
+
And then execute:
|
22
|
+
```bash
|
23
|
+
$ bundle
|
24
|
+
```
|
25
|
+
|
26
|
+
## Configuration
|
27
|
+
1. Mount the endpoint:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
# config/routes.rb
|
31
|
+
Rails.application.routes.draw do
|
32
|
+
# ...
|
33
|
+
mount DeepUnrest::Engine => '/deep_unrest'
|
34
|
+
end
|
35
|
+
```
|
36
|
+
|
37
|
+
2. Set the authentication concern and authorization strategy:
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
# config/initailizers/deep_unrest.rb
|
41
|
+
DeepUnrest.configure do |config|
|
42
|
+
# will be included by the controller as a concern
|
43
|
+
config.authentication_concern = DeviseTokenAuth::Concerns::SetUserByToken
|
44
|
+
|
45
|
+
# will be called from the controller to identify the current user
|
46
|
+
config.get_user = proc { current_user }
|
47
|
+
|
48
|
+
# or if your app has multiple user types:
|
49
|
+
# config.get_user = proc { current_admin || current_user }
|
50
|
+
|
51
|
+
# stategy that will be used to authorize the current user for each resource
|
52
|
+
self.authorization_strategy = DeepUnrest::Authorization::PunditStrategy
|
53
|
+
end
|
54
|
+
```
|
55
|
+
|
56
|
+
## Usage
|
57
|
+
|
58
|
+
### Example 1 - Simple Update:
|
59
|
+
Update attributes on a single `Submission` with id `123`
|
60
|
+
|
61
|
+
##### Request:
|
62
|
+
|
63
|
+
```javascript
|
64
|
+
// PATCH /deep_unrest/update
|
65
|
+
{
|
66
|
+
redirect: '/api/submissions/123',
|
67
|
+
data: [
|
68
|
+
{
|
69
|
+
path: 'submissions.123',
|
70
|
+
attributes: {
|
71
|
+
approved: true
|
72
|
+
}
|
73
|
+
}
|
74
|
+
]
|
75
|
+
}
|
76
|
+
```
|
77
|
+
|
78
|
+
##### 200 Response:
|
79
|
+
The success action is to follow the `redirect` request param
|
80
|
+
(`/api/submissions/123` in the example above).
|
81
|
+
|
82
|
+
```javascript
|
83
|
+
{
|
84
|
+
id: 123,
|
85
|
+
type: 'submissions',
|
86
|
+
attributes: {
|
87
|
+
approved: 'true'
|
88
|
+
}
|
89
|
+
}
|
90
|
+
```
|
91
|
+
|
92
|
+
##### 403 Response:
|
93
|
+
This error will occur when a user attempts to update a resource that is not
|
94
|
+
within their policy scope.
|
95
|
+
|
96
|
+
```javascript
|
97
|
+
[
|
98
|
+
{
|
99
|
+
source: { pointer: { 'submissions.123' } },
|
100
|
+
title: "User with id '456' is not authorized to update Submission with id '123'"
|
101
|
+
}
|
102
|
+
]
|
103
|
+
```
|
104
|
+
|
105
|
+
##### 405 Response:
|
106
|
+
This error will occur when a is allowed to update a resource, but not
|
107
|
+
specified attributes of that resource.
|
108
|
+
|
109
|
+
```javascript
|
110
|
+
[
|
111
|
+
{
|
112
|
+
source: { pointer: { 'submissions.123' } },
|
113
|
+
title: "Attributes [:approved] of Submission not allowed to Applicant with id '789'"
|
114
|
+
}
|
115
|
+
]
|
116
|
+
```
|
117
|
+
|
118
|
+
##### 409 Response:
|
119
|
+
This error will occur when field-level validation fails on any resource
|
120
|
+
updates.
|
121
|
+
|
122
|
+
```javascript
|
123
|
+
[
|
124
|
+
{
|
125
|
+
source: { pointer: { 'submissions.123.name' } },
|
126
|
+
title: 'Name is required',
|
127
|
+
detail: 'is required',
|
128
|
+
}
|
129
|
+
]
|
130
|
+
```
|
131
|
+
|
132
|
+
### Example 2 - Simple Delete:
|
133
|
+
To delete a resource, pass the param `destroy: true` along with the path to that resource.
|
134
|
+
|
135
|
+
##### Request:
|
136
|
+
```javascript
|
137
|
+
// PATCH /deep_unrest/update
|
138
|
+
{
|
139
|
+
data: [
|
140
|
+
{
|
141
|
+
path: 'submissions.123',
|
142
|
+
destroy: true,
|
143
|
+
}
|
144
|
+
]
|
145
|
+
}
|
146
|
+
```
|
147
|
+
|
148
|
+
##### 200 Response:
|
149
|
+
When no redirect path is specified, an empty object will be returned as the
|
150
|
+
response.
|
151
|
+
|
152
|
+
```javascript
|
153
|
+
{}
|
154
|
+
```
|
155
|
+
|
156
|
+
### Example 3 - Simple Create:
|
157
|
+
When creating new resources, the client should assign a temporary ID to the new
|
158
|
+
resource. The temporary ID should be surrounded in brackets (`[]`).
|
159
|
+
|
160
|
+
##### Create Request
|
161
|
+
```javascript
|
162
|
+
// PATCH /deep_unrest/update
|
163
|
+
{
|
164
|
+
data: [
|
165
|
+
{
|
166
|
+
path: 'submissions[1]',
|
167
|
+
attributes: {
|
168
|
+
name: 'testing'
|
169
|
+
}
|
170
|
+
}
|
171
|
+
]
|
172
|
+
}
|
173
|
+
```
|
174
|
+
|
175
|
+
##### Create Errors:
|
176
|
+
All errors regarding the new resource will use the temp ID as the path to the error.
|
177
|
+
|
178
|
+
```javascript
|
179
|
+
[
|
180
|
+
{
|
181
|
+
source: { pointer: { 'submissions[123].name' } },
|
182
|
+
title: 'Name is invalid',
|
183
|
+
detail: 'is invalid',
|
184
|
+
}
|
185
|
+
]
|
186
|
+
```
|
187
|
+
|
188
|
+
### Example 4 - Complex Nested Update:
|
189
|
+
|
190
|
+
This shows an example of a complex operation involving multiple resources. This
|
191
|
+
example will perform the following operations:
|
192
|
+
|
193
|
+
* Change the `name` column of `Submission` with id `123` to `test`
|
194
|
+
* Change the `value` column of `Answer` with id `1` to `yes`
|
195
|
+
* Create a new `Answer` with a value of `No` using temp ID `[1]`
|
196
|
+
* Delete the `Answer` with id `2`
|
197
|
+
|
198
|
+
These operations will be performed within a single `ActiveRecord` transaction.
|
199
|
+
|
200
|
+
##### Complex Nested Update Request
|
201
|
+
|
202
|
+
```javascript
|
203
|
+
// PATCH /deep_unrest/update
|
204
|
+
{
|
205
|
+
redirect: '/api/submissions/123',
|
206
|
+
data: [
|
207
|
+
{
|
208
|
+
path: 'submissions.123',
|
209
|
+
attributes: { name: 'test' }
|
210
|
+
},
|
211
|
+
{
|
212
|
+
path: "submissions.123.questions.456.answers.1",
|
213
|
+
attributes: { value: 'yes' }
|
214
|
+
},
|
215
|
+
{
|
216
|
+
path: "submissions.123.questions.456.answers[1]",
|
217
|
+
attributes: {
|
218
|
+
value: 'no',
|
219
|
+
questionId: 456,
|
220
|
+
submissionId: 123,
|
221
|
+
applicantId: 890
|
222
|
+
}
|
223
|
+
},
|
224
|
+
{
|
225
|
+
path: "submissions.123.questions.456.answers.2",
|
226
|
+
destroy: true
|
227
|
+
}
|
228
|
+
]
|
229
|
+
}
|
230
|
+
```
|
231
|
+
|
232
|
+
### Example 5 - Bulk Updates
|
233
|
+
The following example will mark every `Submission` as `approved`.
|
234
|
+
|
235
|
+
When using an authorization strategy, the scope of the bulk update will be
|
236
|
+
limited to the current user's allowed scope.
|
237
|
+
|
238
|
+
#### Bulk Update Request
|
239
|
+
|
240
|
+
```javascript
|
241
|
+
// PATCH /deep_unrest/update
|
242
|
+
{
|
243
|
+
redirect: '/api/submissions',
|
244
|
+
data: [
|
245
|
+
{
|
246
|
+
path: 'submissions.*',
|
247
|
+
attributes: {
|
248
|
+
approved: true
|
249
|
+
}
|
250
|
+
}
|
251
|
+
]
|
252
|
+
}
|
253
|
+
```
|
254
|
+
|
255
|
+
### Example 6 - Bulk Delete
|
256
|
+
The following example will delete every submission.
|
257
|
+
|
258
|
+
When using an authorization strategy, the scope of the bulk delete will be
|
259
|
+
limited to the current user's allowed scope.
|
260
|
+
|
261
|
+
#### Bulk Delete Request
|
262
|
+
|
263
|
+
```javascript
|
264
|
+
// PATCH /deep_unrest/update
|
265
|
+
{
|
266
|
+
redirect: '/api/submissions',
|
267
|
+
data: [
|
268
|
+
{
|
269
|
+
path: 'submissions.*',
|
270
|
+
destroy: true
|
271
|
+
}
|
272
|
+
]
|
273
|
+
}
|
274
|
+
```
|
275
|
+
|
276
|
+
## TODO
|
277
|
+
|
278
|
+
* Allow the use of filters when performing bulk operations.
|
279
|
+
* How should we handle nested bulk operations? i.e. `submissions.*.questions.*.answers.*`
|
280
|
+
|
281
|
+
## Contributing
|
282
|
+
TDB
|
283
|
+
|
284
|
+
## License
|
285
|
+
The gem is available as open source under the terms of the [WTFPL](http://www.wtfpl.net/).
|
data/Rakefile
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'DeepUnrest'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.md')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
APP_RAKEFILE = File.expand_path("../test/dummy/Rakefile", __FILE__)
|
18
|
+
load 'rails/tasks/engine.rake'
|
19
|
+
|
20
|
+
|
21
|
+
load 'rails/tasks/statistics.rake'
|
22
|
+
|
23
|
+
|
24
|
+
|
25
|
+
require 'bundler/gem_tasks'
|
26
|
+
|
27
|
+
require 'rake/testtask'
|
28
|
+
|
29
|
+
Rake::TestTask.new(:test) do |t|
|
30
|
+
t.libs << 'lib'
|
31
|
+
t.libs << 'test'
|
32
|
+
t.pattern = 'test/**/*_test.rb'
|
33
|
+
t.verbose = false
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
task default: :test
|
@@ -0,0 +1,13 @@
|
|
1
|
+
// This is a manifest file that'll be compiled into application.js, which will include all the files
|
2
|
+
// listed below.
|
3
|
+
//
|
4
|
+
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
|
5
|
+
// or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
|
6
|
+
//
|
7
|
+
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
|
8
|
+
// compiled file. JavaScript code in this file should be added after the last require_* statement.
|
9
|
+
//
|
10
|
+
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
|
11
|
+
// about supported directives.
|
12
|
+
//
|
13
|
+
//= require_tree .
|
@@ -0,0 +1,15 @@
|
|
1
|
+
/*
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
3
|
+
* listed below.
|
4
|
+
*
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
7
|
+
*
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
11
|
+
* It is generally better to create a new file per style scope.
|
12
|
+
*
|
13
|
+
*= require_tree .
|
14
|
+
*= require_self
|
15
|
+
*/
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module DeepUnrest
|
2
|
+
class ApplicationController < ActionController::Base
|
3
|
+
include DeepUnrest.authentication_concern
|
4
|
+
protect_from_forgery with: :null_session
|
5
|
+
|
6
|
+
def context
|
7
|
+
{ current_user: current_user }
|
8
|
+
end
|
9
|
+
|
10
|
+
def update
|
11
|
+
redirect = allowed_params[:redirect]
|
12
|
+
DeepUnrest.perform_update(allowed_params[:data],
|
13
|
+
current_user)
|
14
|
+
if redirect
|
15
|
+
redirect_to redirect
|
16
|
+
else
|
17
|
+
render json: {}, status: 200
|
18
|
+
end
|
19
|
+
rescue DeepUnrest::Unauthorized => err
|
20
|
+
render json: err.message, status: 403
|
21
|
+
rescue DeepUnrest::UnpermittedParams => err
|
22
|
+
render json: err.message, status: 405
|
23
|
+
rescue DeepUnrest::Conflict => err
|
24
|
+
render json: err.message, status: 409
|
25
|
+
end
|
26
|
+
|
27
|
+
def current_user
|
28
|
+
instance_eval &DeepUnrest.get_user
|
29
|
+
end
|
30
|
+
|
31
|
+
def allowed_params
|
32
|
+
params.permit(:redirect,
|
33
|
+
data: [:destroy,
|
34
|
+
:path,
|
35
|
+
{ attributes: {} }])
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Deep unrest</title>
|
5
|
+
<%= stylesheet_link_tag "deep_unrest/application", media: "all" %>
|
6
|
+
<%= javascript_include_tag "deep_unrest/application" %>
|
7
|
+
<%= csrf_meta_tags %>
|
8
|
+
</head>
|
9
|
+
<body>
|
10
|
+
|
11
|
+
<%= yield %>
|
12
|
+
|
13
|
+
</body>
|
14
|
+
</html>
|
data/config/routes.rb
ADDED
data/lib/deep_unrest.rb
ADDED
@@ -0,0 +1,338 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'deep_unrest/engine'
|
4
|
+
|
5
|
+
# Update deeply nested associations wholesale
|
6
|
+
module DeepUnrest
|
7
|
+
class InvalidParentScope < ::StandardError
|
8
|
+
end
|
9
|
+
|
10
|
+
class InvalidAssociation < ::StandardError
|
11
|
+
end
|
12
|
+
|
13
|
+
class InvalidPath < ::StandardError
|
14
|
+
end
|
15
|
+
|
16
|
+
class ValidationError < ::StandardError
|
17
|
+
end
|
18
|
+
|
19
|
+
class InvalidId < ::StandardError
|
20
|
+
end
|
21
|
+
|
22
|
+
class UnpermittedParams < ::StandardError
|
23
|
+
end
|
24
|
+
|
25
|
+
class Conflict < ::StandardError
|
26
|
+
end
|
27
|
+
|
28
|
+
class Unauthorized < ::StandardError
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.to_class(str)
|
32
|
+
str.classify.constantize
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.to_assoc(str)
|
36
|
+
str.pluralize.underscore.to_sym
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.to_update_body_key(str)
|
40
|
+
"#{str.pluralize}Attributes".underscore.to_sym
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.get_resource(type)
|
44
|
+
"#{type.classify}Resource".constantize
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.get_scope_type(id, last, destroy)
|
48
|
+
case id
|
49
|
+
when /^\[\w+\]$/
|
50
|
+
:create
|
51
|
+
when /^\.\w+$/
|
52
|
+
if last
|
53
|
+
if destroy
|
54
|
+
:destroy
|
55
|
+
else
|
56
|
+
:update
|
57
|
+
end
|
58
|
+
else
|
59
|
+
:show
|
60
|
+
end
|
61
|
+
when /^\.\*$/
|
62
|
+
if last
|
63
|
+
if destroy
|
64
|
+
:destroy_all
|
65
|
+
else
|
66
|
+
:update_all
|
67
|
+
end
|
68
|
+
else
|
69
|
+
:index
|
70
|
+
end
|
71
|
+
else
|
72
|
+
raise InvalidId, "Unknown ID format: #{id}"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# verify that this is an actual association of the parent class.
|
77
|
+
def self.add_parent_scope(parent, type)
|
78
|
+
reflection = parent[:klass].reflect_on_association(to_assoc(type))
|
79
|
+
{ base: parent[:scope], method: reflection.name }
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.validate_association(parent, type)
|
83
|
+
return unless parent
|
84
|
+
reflection = parent[:klass].reflect_on_association(to_assoc(type))
|
85
|
+
raise NoMethodError unless reflection.klass == to_class(type)
|
86
|
+
unless parent[:id]
|
87
|
+
raise InvalidParentScope, 'Unable to update associations of collections '\
|
88
|
+
"('#{parent[:type]}.#{type}')."
|
89
|
+
end
|
90
|
+
rescue NoMethodError
|
91
|
+
raise InvalidAssociation, "'#{parent[:type]}' has no association '#{type}'"
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.get_scope(scope_type, memo, type, id_str = nil)
|
95
|
+
case scope_type
|
96
|
+
when :show, :update, :destroy
|
97
|
+
id = /^\.(?<id>\d+)$/.match(id_str)[:id]
|
98
|
+
{ base: to_class(type), method: :find, arguments: [id] }
|
99
|
+
when :update_all, :index
|
100
|
+
if memo.empty?
|
101
|
+
{ base: to_class(type), method: :all }
|
102
|
+
else
|
103
|
+
add_parent_scope(memo[memo.size - 1], type)
|
104
|
+
end
|
105
|
+
# TODO: delete this condition
|
106
|
+
when :all
|
107
|
+
{ base: to_class(type), method: :all }
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def self.parse_path(path)
|
112
|
+
rx = /(?<type>\w+)(?<id>(?:\[|\.)[\w+\*\]]+)/
|
113
|
+
result = path.scan(rx)
|
114
|
+
unless result.map { |res| res.join('') }.join('.') == path
|
115
|
+
raise InvalidPath, "Invalid path: #{path}"
|
116
|
+
end
|
117
|
+
result
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.update_indices(indices, type)
|
121
|
+
indices[type] ||= 0
|
122
|
+
indices[type] += 1
|
123
|
+
indices
|
124
|
+
end
|
125
|
+
|
126
|
+
def self.parse_attributes(type, scope_type, attributes, user)
|
127
|
+
p = JSONAPI::RequestParser.new
|
128
|
+
resource = get_resource(type)
|
129
|
+
p.resource_klass = resource
|
130
|
+
ctx = { current_user: user }
|
131
|
+
opts = if scope_type == :create
|
132
|
+
resource.creatable_fields(ctx)
|
133
|
+
else
|
134
|
+
resource.updatable_fields(ctx)
|
135
|
+
end
|
136
|
+
|
137
|
+
p.parse_params({ attributes: attributes }, opts)[:attributes]
|
138
|
+
rescue JSONAPI::Exceptions::ParametersNotAllowed
|
139
|
+
unpermitted_keys = attributes.keys.map(&:to_sym) - opts
|
140
|
+
msg = "Attributes #{unpermitted_keys} of #{type.classify} not allowed"
|
141
|
+
msg += " to #{user.class} with id '#{user.id}'" if user
|
142
|
+
raise UnpermittedParams, [{ title: msg }].to_json
|
143
|
+
end
|
144
|
+
|
145
|
+
def self.collect_action_scopes(operation)
|
146
|
+
resources = parse_path(operation[:path])
|
147
|
+
resources.each_with_object([]) do |(type, id), memo|
|
148
|
+
validate_association(memo.last, type)
|
149
|
+
scope_type = get_scope_type(id,
|
150
|
+
memo.size == resources.size - 1,
|
151
|
+
operation[:destroy])
|
152
|
+
scope = get_scope(scope_type, memo, type, id)
|
153
|
+
context = { type: type,
|
154
|
+
scope_type: scope_type,
|
155
|
+
scope: scope,
|
156
|
+
klass: to_class(type),
|
157
|
+
id: id }
|
158
|
+
|
159
|
+
context[:path] = operation[:path] unless scope_type == :show
|
160
|
+
memo.push(context)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def self.collect_all_scopes(params)
|
165
|
+
idx = {}
|
166
|
+
params.map { |operation| collect_action_scopes(operation) }
|
167
|
+
.flatten
|
168
|
+
.uniq
|
169
|
+
.map do |op|
|
170
|
+
unless op[:scope_type] == :show
|
171
|
+
op[:index] = update_indices(idx, op[:type])[op[:type]] - 1
|
172
|
+
end
|
173
|
+
op
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def self.parse_id(id_str)
|
178
|
+
id_match = id_str.match(/^\.(?<id>\d+)$/)
|
179
|
+
id_match && id_match[:id]
|
180
|
+
end
|
181
|
+
|
182
|
+
def self.set_action(cursor, operation, type, user)
|
183
|
+
# TODO: this is horrible. find a better way to go about this
|
184
|
+
action = get_scope_type(parse_path(operation[:path]).last[1],
|
185
|
+
true,
|
186
|
+
operation[:destroy])
|
187
|
+
|
188
|
+
case action
|
189
|
+
when :destroy
|
190
|
+
cursor[:_destroy] = true
|
191
|
+
when :update, :create, :update_all
|
192
|
+
cursor.merge! parse_attributes(type,
|
193
|
+
operation[:action],
|
194
|
+
operation[:attributes],
|
195
|
+
user)
|
196
|
+
end
|
197
|
+
cursor
|
198
|
+
end
|
199
|
+
|
200
|
+
def self.get_mutation_cursor(memo, cursor, addr, type, id, destroy)
|
201
|
+
if memo
|
202
|
+
cursor[addr] = [{}]
|
203
|
+
next_cursor = cursor[addr][0]
|
204
|
+
else
|
205
|
+
cursor = {}
|
206
|
+
type_sym = type.to_sym
|
207
|
+
if destroy
|
208
|
+
method = id ? :update : :destroy_all
|
209
|
+
else
|
210
|
+
method = id ? :update : :update_all
|
211
|
+
end
|
212
|
+
klass = to_class(type)
|
213
|
+
body = {}
|
214
|
+
body[klass.primary_key.to_sym] = id if id
|
215
|
+
cursor[type_sym] = {
|
216
|
+
klass: klass
|
217
|
+
}
|
218
|
+
cursor[type_sym][:operations] = {}
|
219
|
+
cursor[type_sym][:operations][id] = {}
|
220
|
+
cursor[type_sym][:operations][id][method] = {
|
221
|
+
method: method,
|
222
|
+
body: body
|
223
|
+
}
|
224
|
+
memo = cursor
|
225
|
+
next_cursor = cursor[type_sym][:operations][id][method][:body]
|
226
|
+
end
|
227
|
+
[memo, next_cursor]
|
228
|
+
end
|
229
|
+
|
230
|
+
def self.build_mutation_fragment(op, user, rest = nil, memo = nil, cursor = nil, type = nil)
|
231
|
+
rest ||= parse_path(op[:path])
|
232
|
+
|
233
|
+
if rest.empty?
|
234
|
+
set_action(cursor, op, type, user)
|
235
|
+
return memo
|
236
|
+
end
|
237
|
+
|
238
|
+
type, id_str = rest.shift
|
239
|
+
addr = to_update_body_key(type)
|
240
|
+
id = parse_id(id_str)
|
241
|
+
|
242
|
+
memo, next_cursor = get_mutation_cursor(memo, cursor, addr, type, id, op[:destroy])
|
243
|
+
|
244
|
+
next_cursor[:id] = id if id
|
245
|
+
build_mutation_fragment(op, user, rest, memo, next_cursor, type)
|
246
|
+
end
|
247
|
+
|
248
|
+
def self.build_mutation_body(ops, user)
|
249
|
+
ops.each_with_object({}) do |op, memo|
|
250
|
+
memo.deeper_merge(build_mutation_fragment(op, user))
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
def self.mutate(mutation, user)
|
255
|
+
mutation.map do |_, item|
|
256
|
+
item[:operations].map do |id, ops|
|
257
|
+
ops.map do |_, action|
|
258
|
+
case action[:method]
|
259
|
+
when :update_all
|
260
|
+
DeepUnrest.authorization_strategy
|
261
|
+
.get_authorized_scope(user, item[:klass])
|
262
|
+
.update(action[:body])
|
263
|
+
nil
|
264
|
+
when :destroy_all
|
265
|
+
DeepUnrest.authorization_strategy
|
266
|
+
.get_authorized_scope(user, item[:klass])
|
267
|
+
.destroy_all
|
268
|
+
nil
|
269
|
+
when :update
|
270
|
+
item[:klass].update(id, action[:body])
|
271
|
+
when :create
|
272
|
+
item[:klass].create(action[:body])
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
def self.parse_error_path(key)
|
280
|
+
rx = /(((^|\.)(?<type>[^\.\[]+)(?:\[(?<idx>\d+)\])\.)?(?<field>[\w\.]+)$)/
|
281
|
+
rx.match(key)
|
282
|
+
end
|
283
|
+
|
284
|
+
def self.format_errors(operation, path_info, values)
|
285
|
+
if operation
|
286
|
+
return values.map do |msg|
|
287
|
+
{ title: "#{path_info[:field].humanize} #{msg}",
|
288
|
+
detail: msg,
|
289
|
+
source: { pointer: "#{operation[:path]}.#{path_info[:field]}" } }
|
290
|
+
end
|
291
|
+
end
|
292
|
+
values.map do |msg|
|
293
|
+
{ title: msg, detail: msg, source: { pointer: nil } }
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
def self.map_errors_to_param_keys(scopes, ops)
|
298
|
+
ops.map do |errors|
|
299
|
+
errors.map do |key, values|
|
300
|
+
path_info = parse_error_path(key.to_s)
|
301
|
+
operation = scopes.find do |s|
|
302
|
+
s[:type] == path_info[:type] && s[:index] == path_info[:idx].to_i
|
303
|
+
end
|
304
|
+
|
305
|
+
format_errors(operation, path_info, values)
|
306
|
+
end
|
307
|
+
end.flatten
|
308
|
+
end
|
309
|
+
|
310
|
+
def self.perform_update(params, user)
|
311
|
+
# identify requested scope(s)
|
312
|
+
scopes = collect_all_scopes(params)
|
313
|
+
|
314
|
+
# authorize user for requested scope(s)
|
315
|
+
DeepUnrest.authorization_strategy.authorize(scopes, user).flatten
|
316
|
+
|
317
|
+
# bulid update arguments
|
318
|
+
mutations = build_mutation_body(params, user)
|
319
|
+
|
320
|
+
# perform update
|
321
|
+
results = mutate(mutations, user).flatten
|
322
|
+
|
323
|
+
# check results for errors
|
324
|
+
errors = results.compact
|
325
|
+
.map(&:errors)
|
326
|
+
.map(&:messages)
|
327
|
+
.reject(&:empty?)
|
328
|
+
.compact
|
329
|
+
|
330
|
+
return if errors.empty?
|
331
|
+
|
332
|
+
# map errors to their sources
|
333
|
+
formatted_errors = { errors: map_errors_to_param_keys(scopes, errors) }
|
334
|
+
|
335
|
+
# raise error if there are any errors
|
336
|
+
raise Conflict, formatted_errors.to_json unless formatted_errors.empty?
|
337
|
+
end
|
338
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DeepUnrest
|
4
|
+
module Authorization
|
5
|
+
class PunditStrategy < DeepUnrest::Authorization::BaseStrategy
|
6
|
+
def self.get_policy_name(method)
|
7
|
+
"#{method}?".to_sym
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.get_policy(klass)
|
11
|
+
"#{klass}Policy".constantize
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.get_authorized_scope(user, klass)
|
15
|
+
policy = get_policy(klass)
|
16
|
+
policy::Scope.new(user, klass).resolve
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.auth_error_message(user, scope)
|
20
|
+
actor = "#{user.class.name} with id '#{user.id}'"
|
21
|
+
target = scope[:type].classify
|
22
|
+
unless %i[create update_all].include? scope[:scope_type]
|
23
|
+
target += " with id '#{scope[:scope][:arguments].first}'"
|
24
|
+
end
|
25
|
+
msg = "#{actor} is not authorized to #{scope[:scope_type]} #{target}"
|
26
|
+
|
27
|
+
[{ title: msg,
|
28
|
+
source: { pointer: scope[:path] } }].to_json
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.get_entity_authorization(scope, user)
|
32
|
+
if %i[create update_all index destroy_all].include?(scope[:scope_type])
|
33
|
+
target = scope[:klass]
|
34
|
+
else
|
35
|
+
target = scope[:scope][:base].send(scope[:scope][:method],
|
36
|
+
*scope[:scope][:arguments])
|
37
|
+
end
|
38
|
+
|
39
|
+
Pundit.policy!(user, target).send(get_policy_name(scope[:scope_type]))
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.authorize(scopes, user)
|
43
|
+
scopes.each do |s|
|
44
|
+
allowed = get_entity_authorization(s, user)
|
45
|
+
unless allowed
|
46
|
+
raise DeepUnrest::Unauthorized, auth_error_message(user, s)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module DeepUnrest
|
2
|
+
module Concerns
|
3
|
+
module NullConcern
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
included do
|
6
|
+
before_action :issue_warning
|
7
|
+
end
|
8
|
+
|
9
|
+
protected
|
10
|
+
|
11
|
+
def issue_warning
|
12
|
+
# TODO: get link to docs for this
|
13
|
+
logger.info 'Warning: no concern set for DeepUnrest. Read the docs.'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'deep_unrest/authorization/base_strategy'
|
2
|
+
require 'deep_unrest/authorization/none_strategy'
|
3
|
+
require 'deep_unrest/authorization/pundit_strategy'
|
4
|
+
require 'deep_unrest/concerns/null_concern'
|
5
|
+
|
6
|
+
module DeepUnrest
|
7
|
+
class Engine < ::Rails::Engine
|
8
|
+
isolate_namespace DeepUnrest
|
9
|
+
end
|
10
|
+
|
11
|
+
mattr_accessor :authorization_strategy
|
12
|
+
mattr_accessor :authentication_concern
|
13
|
+
mattr_accessor :get_user
|
14
|
+
|
15
|
+
self.authorization_strategy = DeepUnrest::Authorization::PunditStrategy
|
16
|
+
self.authentication_concern = DeepUnrest::Concerns::NullConcern
|
17
|
+
self.get_user = proc { current_user }
|
18
|
+
|
19
|
+
def self.configure(&_block)
|
20
|
+
yield self
|
21
|
+
end
|
22
|
+
end
|
metadata
ADDED
@@ -0,0 +1,190 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: deep_unrest
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Lynn Hurley
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-05-17 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 5.1.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 5.1.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: jsonapi-utils
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.6.0.beta
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.6.0.beta
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: jsonapi-resources
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.9.0
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 0.9.0
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: sqlite3
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: omniauth-github
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - '='
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 1.2.3
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - '='
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 1.2.3
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: pundit
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 1.1.0
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 1.1.0
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: dragonfly
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - '='
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 1.1.2
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - '='
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: 1.1.2
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: database_cleaner
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - '='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: 1.6.0
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - '='
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: 1.6.0
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: faker
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - '='
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: 1.7.3
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - '='
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: 1.7.3
|
139
|
+
description: Update multiple or deeply nested JSONAPI resources
|
140
|
+
email:
|
141
|
+
- lynn.dylan.hurley@gmail.com
|
142
|
+
executables: []
|
143
|
+
extensions: []
|
144
|
+
extra_rdoc_files: []
|
145
|
+
files:
|
146
|
+
- README.md
|
147
|
+
- Rakefile
|
148
|
+
- app/assets/config/deep_unrest_manifest.js
|
149
|
+
- app/assets/javascripts/deep_unrest/application.js
|
150
|
+
- app/assets/stylesheets/deep_unrest/application.css
|
151
|
+
- app/controllers/deep_unrest/application_controller.rb
|
152
|
+
- app/helpers/deep_unrest/application_helper.rb
|
153
|
+
- app/jobs/deep_unrest/application_job.rb
|
154
|
+
- app/mailers/deep_unrest/application_mailer.rb
|
155
|
+
- app/models/deep_unrest/application_record.rb
|
156
|
+
- app/views/layouts/deep_unrest/application.html.erb
|
157
|
+
- config/routes.rb
|
158
|
+
- lib/deep_unrest.rb
|
159
|
+
- lib/deep_unrest/authorization/base_strategy.rb
|
160
|
+
- lib/deep_unrest/authorization/none_strategy.rb
|
161
|
+
- lib/deep_unrest/authorization/pundit_strategy.rb
|
162
|
+
- lib/deep_unrest/concerns/null_concern.rb
|
163
|
+
- lib/deep_unrest/engine.rb
|
164
|
+
- lib/deep_unrest/version.rb
|
165
|
+
- lib/tasks/deep_unrest_tasks.rake
|
166
|
+
homepage: https://github.com/graveflex/deep_unrest
|
167
|
+
licenses:
|
168
|
+
- MIT
|
169
|
+
metadata: {}
|
170
|
+
post_install_message:
|
171
|
+
rdoc_options: []
|
172
|
+
require_paths:
|
173
|
+
- lib
|
174
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
175
|
+
requirements:
|
176
|
+
- - ">="
|
177
|
+
- !ruby/object:Gem::Version
|
178
|
+
version: '0'
|
179
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
180
|
+
requirements:
|
181
|
+
- - ">="
|
182
|
+
- !ruby/object:Gem::Version
|
183
|
+
version: '0'
|
184
|
+
requirements: []
|
185
|
+
rubyforge_project:
|
186
|
+
rubygems_version: 2.6.6
|
187
|
+
signing_key:
|
188
|
+
specification_version: 4
|
189
|
+
summary: Update multiple or deeply nested JSONAPI resources
|
190
|
+
test_files: []
|