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 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: []