Skip to content

Commit a8be1e6

Browse files
author
Lee Richmond
committed
Add before_commit hooks
These hooks run after validating the whole graph, but before closing the transaction. Helpful for things like "contact this service after saving, but rollback if the service is down". Moves the existing sideload hooks to the same place (the previous behavior was to fire before validations). Implemented by a Hook accumulator that uses Thread.current. I've tried this a few different ways but recursive functions that return a mash of objects seem to add a lot of complexity to the code for no real reason. The premise of registering hooks during a complex process, then calling those hooks later, is simpler. This does add a small amount of duplication between the create/update actions and the destroy action. This is because we're currently not supporting DELETE requests with a body (nested deletes) so the processing logic is different. Think this can be assimliated in a separate PR.
1 parent 85e9936 commit a8be1e6

File tree

10 files changed

+417
-27
lines changed

10 files changed

+417
-27
lines changed

lib/jsonapi_compliable.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
require "jsonapi_compliable/util/persistence"
2424
require "jsonapi_compliable/util/validation_response"
2525
require "jsonapi_compliable/util/sideload"
26+
require "jsonapi_compliable/util/hooks"
2627

2728
# require correct jsonapi-rb before extensions
2829
if defined?(Rails)

lib/jsonapi_compliable/base.rb

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,20 @@ def jsonapi_update
226226
end
227227
end
228228

229+
# Delete the model
230+
# Any error, including validation errors, will roll back the transaction.
231+
#
232+
# Note: +before_commit+ hooks still run unless excluded
233+
#
234+
# @return [Util::ValidationResponse]
229235
def jsonapi_destroy
230-
_persist do
231-
jsonapi_resource.destroy(params[:id])
236+
jsonapi_resource.transaction do
237+
model = jsonapi_resource.destroy(params[:id])
238+
validator = ::JsonapiCompliable::Util::ValidationResponse.new \
239+
model, deserialized_params
240+
validator.validate!
241+
jsonapi_resource.before_commit(model, :destroy)
242+
validator
232243
end
233244
end
234245

@@ -290,6 +301,18 @@ def default_jsonapi_render_options
290301

291302
private
292303

304+
def _persist
305+
jsonapi_resource.transaction do
306+
::JsonapiCompliable::Util::Hooks.record do
307+
model = yield
308+
validator = ::JsonapiCompliable::Util::ValidationResponse.new \
309+
model, deserialized_params
310+
validator.validate!
311+
validator
312+
end
313+
end
314+
end
315+
293316
def force_includes?
294317
not deserialized_params.data.nil?
295318
end
@@ -299,16 +322,5 @@ def perform_render_jsonapi(opts)
299322
JSONAPI::Serializable::Renderer.new
300323
.render(opts.delete(:jsonapi), opts).to_json
301324
end
302-
303-
def _persist
304-
validation_response = nil
305-
jsonapi_resource.transaction do
306-
object = yield
307-
validation_response = Util::ValidationResponse.new \
308-
object, deserialized_params
309-
raise Errors::ValidationError unless validation_response.to_a[1]
310-
end
311-
validation_response
312-
end
313325
end
314326
end

lib/jsonapi_compliable/errors.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
module JsonapiCompliable
22
module Errors
33
class BadFilter < StandardError; end
4-
class ValidationError < StandardError; end
4+
5+
class ValidationError < StandardError
6+
attr_reader :validation_response
7+
8+
def initialize(validation_response)
9+
@validation_response = validation_response
10+
end
11+
end
512

613
class UnsupportedPageSize < StandardError
714
def initialize(size, max)

lib/jsonapi_compliable/resource.rb

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,29 @@ def self.model(klass)
256256
config[:model] = klass
257257
end
258258

259+
# Register a hook that fires AFTER all validation logic has run -
260+
# including validation of nested objects - but BEFORE the transaction
261+
# has closed.
262+
#
263+
# Helpful for things like "contact this external service after persisting
264+
# data, but roll everything back if there's an error making the service call"
265+
#
266+
# @param [Hash] +only: [:create, :update, :destroy]+
267+
def self.before_commit(only: [:create, :update, :destroy], &blk)
268+
only.each do |verb|
269+
config[:before_commit][verb] = blk
270+
end
271+
end
272+
273+
# Actually fire the before commit hooks
274+
#
275+
# @see .before_commit
276+
# @api private
277+
def before_commit(model, method)
278+
hook = self.class.config[:before_commit][method]
279+
hook.call(model) if hook
280+
end
281+
259282
# Define custom sorting logic
260283
#
261284
# @example Sort on alternate table
@@ -390,6 +413,7 @@ def self.config
390413
sorting: nil,
391414
pagination: nil,
392415
model: nil,
416+
before_commit: {},
393417
adapter: Adapters::Abstract.new
394418
}
395419
end
@@ -704,7 +728,8 @@ def transaction
704728
adapter.transaction(model) do
705729
response = yield
706730
end
707-
rescue Errors::ValidationError
731+
rescue Errors::ValidationError => e
732+
response = e.validation_response
708733
end
709734
response
710735
end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module JsonapiCompliable
2+
module Util
3+
class Hooks
4+
def self.record
5+
self.hooks = []
6+
begin
7+
yield.tap { run }
8+
ensure
9+
self.hooks = []
10+
end
11+
end
12+
13+
def self._hooks
14+
Thread.current[:_compliable_hooks] ||= []
15+
end
16+
17+
def self.hooks=(val)
18+
Thread.current[:_compliable_hooks] = val
19+
end
20+
21+
def self.add(prc)
22+
self._hooks.unshift(prc)
23+
end
24+
25+
def self.run
26+
self._hooks.each { |h| h.call }
27+
end
28+
end
29+
end
30+
end

lib/jsonapi_compliable/util/persistence.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ def initialize(resource, meta, attributes, relationships, caller_model)
2727
# * associate parent objects with current object
2828
# * process children
2929
# * associate children
30+
# * record hooks for later playback
3031
# * run post-process sideload hooks
3132
# * return current object
3233
#
33-
# @return the persisted model instance
34+
# @return a model instance
3435
def run
3536
parents = process_belongs_to(@relationships)
3637
update_foreign_key_for_parents(parents)
@@ -45,13 +46,20 @@ def run
4546
end
4647

4748
associate_children(persisted, children) unless @meta[:method] == :destroy
49+
4850
post_process(persisted, parents)
4951
post_process(persisted, children)
52+
before_commit = -> { @resource.before_commit(persisted, @meta[:method]) }
53+
add_hook(before_commit)
5054
persisted
5155
end
5256

5357
private
5458

59+
def add_hook(prc)
60+
::JsonapiCompliable::Util::Hooks.add(prc)
61+
end
62+
5563
# The child's attributes should be modified to nil-out the
5664
# foreign_key when the parent is being destroyed or disassociated
5765
#
@@ -147,7 +155,8 @@ def post_process(caller_model, processed)
147155
groups.each_pair do |method, group|
148156
group.group_by { |g| g[:sideload] }.each_pair do |sideload, members|
149157
objects = members.map { |x| x[:object] }
150-
sideload.fire_hooks!(caller_model, objects, method)
158+
hook = -> { sideload.fire_hooks!(caller_model, objects, method) }
159+
add_hook(hook)
151160
end
152161
end
153162
end

lib/jsonapi_compliable/util/validation_response.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ def to_a
3030
[object, success?]
3131
end
3232

33+
def validate!
34+
unless success?
35+
raise ::JsonapiCompliable::Errors::ValidationError.new(self)
36+
end
37+
self
38+
end
39+
3340
private
3441

3542
def valid_object?(object)

spec/fixtures/employee_directory.rb

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@
4848

4949
class ApplicationRecord < ActiveRecord::Base
5050
self.abstract_class = true
51+
attr_accessor :force_validation_error
52+
53+
before_save do
54+
add_validation_error if force_validation_error
55+
56+
if Rails::VERSION::MAJOR >= 5
57+
throw(:abort) if errors.present?
58+
else
59+
errors.blank?
60+
end
61+
end
62+
63+
def add_validation_error
64+
errors.add(:base, 'Forced validation error')
65+
end
5166
end
5267

5368
class Classification < ApplicationRecord
@@ -74,8 +89,6 @@ class HomeOffice < ApplicationRecord
7489
end
7590

7691
class Employee < ApplicationRecord
77-
attr_accessor :force_validation_error
78-
7992
belongs_to :workspace, polymorphic: true
8093
belongs_to :classification
8194
has_many :positions
@@ -98,10 +111,6 @@ class Employee < ApplicationRecord
98111
errors.blank?
99112
end
100113
end
101-
102-
def add_validation_error
103-
errors.add(:base, 'Forced validation error')
104-
end
105114
end
106115

107116
class Position < ApplicationRecord

0 commit comments

Comments
 (0)