deep_unrest 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,2 @@
1
+ //= link_directory ../javascripts/deep_unrest .js
2
+ //= link_directory ../stylesheets/deep_unrest .css
@@ -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,4 @@
1
+ module DeepUnrest
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module DeepUnrest
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module DeepUnrest
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: 'from@example.com'
4
+ layout 'mailer'
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module DeepUnrest
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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
@@ -0,0 +1,3 @@
1
+ DeepUnrest::Engine.routes.draw do
2
+ patch 'update', to: 'application#update'
3
+ end
@@ -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,6 @@
1
+ module DeepUnrest
2
+ module Authorization
3
+ class BaseStrategy
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module DeepUnrest
2
+ module Authorization
3
+ class NoneStrategy < DeepUnrest::Authorization::BaseStrategy
4
+ end
5
+ end
6
+ 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
@@ -0,0 +1,3 @@
1
+ module DeepUnrest
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :deep_unrest do
3
+ # # Task goes here
4
+ # 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: []