deep_unrest 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/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
|
+
[![CircleCI](https://circleci.com/gh/graveflex/deep_unrest.svg?style=svg)](https://circleci.com/gh/graveflex/deep_unrest)
|
4
|
+
[![Test Coverage](https://codeclimate.com/repos/591cc05fbd15c32ce10014b4/badges/36013471f0d2c4c6f875/coverage.svg)](https://codeclimate.com/repos/591cc05fbd15c32ce10014b4/coverage)
|
5
|
+
[![Code Climate](https://codeclimate.com/repos/591cc05fbd15c32ce10014b4/badges/36013471f0d2c4c6f875/gpa.svg)](https://codeclimate.com/repos/591cc05fbd15c32ce10014b4/feed)
|
6
|
+
[![Issue Count](https://codeclimate.com/repos/591cc05fbd15c32ce10014b4/badges/36013471f0d2c4c6f875/issue_count.svg)](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: []
|