Skip to content

Commit 89020f5

Browse files
authored
Add before_commit hooks (#72)
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 c28b7bc commit 89020f5

File tree

10 files changed

+420
-27
lines changed

10 files changed

+420
-27
lines changed

lib/jsonapi_compliable.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
require "jsonapi_compliable/util/persistence"
2525
require "jsonapi_compliable/util/validation_response"
2626
require "jsonapi_compliable/util/sideload"
27+
require "jsonapi_compliable/util/hooks"
2728

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

lib/jsonapi_compliable/base.rb

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,20 @@ def jsonapi_update
253253
end
254254
end
255255

256+
# Delete the model
257+
# Any error, including validation errors, will roll back the transaction.
258+
#
259+
# Note: +before_commit+ hooks still run unless excluded
260+
#
261+
# @return [Util::ValidationResponse]
256262
def jsonapi_destroy
257-
_persist do
258-
jsonapi_resource.destroy(params[:id])
263+
jsonapi_resource.transaction do
264+
model = jsonapi_resource.destroy(params[:id])
265+
validator = ::JsonapiCompliable::Util::ValidationResponse.new \
266+
model, deserialized_params
267+
validator.validate!
268+
jsonapi_resource.before_commit(model, :destroy)
269+
validator
259270
end
260271
end
261272

@@ -321,6 +332,18 @@ def default_jsonapi_render_options
321332

322333
private
323334

335+
def _persist
336+
jsonapi_resource.transaction do
337+
::JsonapiCompliable::Util::Hooks.record do
338+
model = yield
339+
validator = ::JsonapiCompliable::Util::ValidationResponse.new \
340+
model, deserialized_params
341+
validator.validate!
342+
validator
343+
end
344+
end
345+
end
346+
324347
def force_includes?
325348
not deserialized_params.data.nil?
326349
end
@@ -330,16 +353,5 @@ def perform_render_jsonapi(opts)
330353
JSONAPI::Serializable::Renderer.new
331354
.render(opts.delete(:jsonapi), opts).to_json
332355
end
333-
334-
def _persist
335-
validation_response = nil
336-
jsonapi_resource.transaction do
337-
object = yield
338-
validation_response = Util::ValidationResponse.new \
339-
object, deserialized_params
340-
raise Errors::ValidationError unless validation_response.to_a[1]
341-
end
342-
validation_response
343-
end
344356
end
345357
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 MissingSerializer < StandardError
714
def initialize(class_name, serializer_name)

lib/jsonapi_compliable/resource.rb

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

260+
# Register a hook that fires AFTER all validation logic has run -
261+
# including validation of nested objects - but BEFORE the transaction
262+
# has closed.
263+
#
264+
# Helpful for things like "contact this external service after persisting
265+
# data, but roll everything back if there's an error making the service call"
266+
#
267+
# @param [Hash] +only: [:create, :update, :destroy]+
268+
def self.before_commit(only: [:create, :update, :destroy], &blk)
269+
Array(only).each do |verb|
270+
config[:before_commit][verb] = blk
271+
end
272+
end
273+
274+
# Actually fire the before commit hooks
275+
#
276+
# @see .before_commit
277+
# @api private
278+
def before_commit(model, method)
279+
hook = self.class.config[:before_commit][method]
280+
hook.call(model) if hook
281+
end
282+
260283
# Define custom sorting logic
261284
#
262285
# @example Sort on alternate table
@@ -391,6 +414,7 @@ def self.config
391414
sorting: nil,
392415
pagination: nil,
393416
model: nil,
417+
before_commit: {},
394418
adapter: Adapters::Abstract.new
395419
}
396420
end
@@ -705,7 +729,8 @@ def transaction
705729
adapter.transaction(model) do
706730
response = yield
707731
end
708-
rescue Errors::ValidationError
732+
rescue Errors::ValidationError => e
733+
response = e.validation_response
709734
end
710735
response
711736
end

lib/jsonapi_compliable/util/hooks.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
private_class_method :_hooks
17+
18+
def self.hooks=(val)
19+
Thread.current[:_compliable_hooks] = val
20+
end
21+
22+
# Because hooks will be added from the outer edges of
23+
# the graph, working inwards
24+
def self.add(prc)
25+
_hooks.unshift(prc)
26+
end
27+
28+
def self.run
29+
_hooks.each { |h| h.call }
30+
end
31+
end
32+
end
33+
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)