Skip to content

Instantly share code, notes, and snippets.

@paul paul/async.rb
Created Dec 25, 2018

Embed
What would you like to do?
Implementations of useful step adapters for dry-transaction
# frozen_string_literal: true
module Tesseract
module Transaction
module Steps
# Executes the step in a background job. Argument is either an ActiveJob
# or another Transaction (or anything that implements `#perform_later`.
#
# If the provided transaction implements a `validate` step, then that
# validator will be called on the input before the job is enqueued. This
# prevents us from enqueuing jobs with garbage arguemnts that can never
# be run, and limits the params passed through the message body into only
# those relevant to the job.
#
# Additionally, ActiveJob only allows for the serialization of a few
# types of values into the message, Strings, Numbers and
# ActiveRecord::Model instances (via globalid). Anything else will raise
# an ActiveJob::SerializationError. Calling the validator beforehand
# helps strip those out as well.
#
# Usage:
#
# async DeliverMessageJob # A job
# async Conversations::Open # A transaction
#
module Async
extend ActiveSupport::Concern
module ClassMethods
def async(job)
method_name = job.name.underscore.intern
step method_name
define_method method_name do |input|
if validator = job&.validator
result = validator.call(input)
return result unless result.success?
job.perform_async(result.output)
else
job.perform_async(input)
end
Success(input)
end
end
def perform_later(*args)
TransactionJob.perform_later(transaction_class_name: name, args: args)
end
end
end
end
end
end
# frozen_string_literal: true
module Tesseract
module Transaction
module Steps
# Adds an "authorize" step that uses Pundit to check for authorization as
# part of running the transaction. It expects input to be a hash with at
# least a `:user` key containing the "user" object for pundit. The step
# itself expects the class to authorize, or a symbol to use as a key in
# the input hash to find the object to authorize. Finally, the check can
# optionally be passed in, or will be inferred from the transaction class
# name.
#
# Usage:
#
# class Post::Update
# include Dry::Transaction
#
# # given input: { user: #<User id:42>, post: #<Post id:6> }
# authorize Post # => Pundit.policy(input[:user], Post).update?
# authorize :post # => Pundit.policy(input[:user], input[:post]).update?
# authorize :post, :edit? # => Pundit.policy(input[:user], input[:post]).edit?
# end
#
module Authorize
extend ActiveSupport::Concern
module ClassMethods
def authorize(key, query = nil)
step :authorize
define_method :authorize do |params|
object = if key.is_a? Class
key
else
params[key]
end
policy = Pundit.policy!(params[:user], object)
query ||= self.class.name.demodulize.underscore + "?"
if policy.public_send(query)
Success(params)
else
Failure(query: query, record: object, policy: policy)
end
end
end
end
end
end
end
end
# frozen_string_literal: true
module Tesseract
module Transaction
module Steps
# Adds a "merge" step that expects the input to the step to be a hash,
# and then merges the successful result of the step into the input hash.
# If the step results in a Failure then that Failure is returned instead.
#
# If the return value of the step is not a hash, then the return value is
# merged into the input hash using the step name as the key.
#
# Usage:
#
# merge :user
# merge :lookup_metadata
#
# # input: { user_id: 42, name: "Chuck" }
# def user(user_id:, **)
# User.find(user_id)
# end
# # output: { user_id: 42, name: "Chuck", user: #<User id:42> }
#
# def lookup_metadata(user:,.**)
# resp = APIClient.get_email(user.client_id)
# { email: resp["user_email"], fists: resp["fists"]["items"].size }
# end
#
# # output: { user_id: 42, name: "Chuck", user: #<User id:42>, email: "chuck@example", fists: 2 }
#
module Merge
class MergeStepAdapter < Dry::Transaction::StepAdapters
include Dry::Monads::Result::Mixin
def call(operation, _options, input)
result = operation.call(input[0])
return result if result.try(:failure?)
value = result.try(:value!) || result || {}
value = { operation.operation.name => value } unless value.is_a?(Hash)
Success(input[0].merge(value))
end
end
Dry::Transaction::StepAdapters.register(:merge, MergeStepAdapter.new) unless Dry::Transaction::StepAdapters.key?(:merge)
end
end
end
end
# frozen_string_literal: true
module Tesseract
module Transaction
module Steps
# Add a "tap" step that works similarly to the built-in "tee" step in
# that if the step is successful it ignores the output and returns the
# input. However, if the step returns a Failure, then that failure is
# not ignored as it is with "tee", but is instead returned directly.
#
# Usage:
#
# tap :update_metadata
#
# def update_metadata(user:, **)
# resp = APIClient.update_data(name: user.name)
# return Failure(resp) if resp.code != 200
# # on success this will implicitly return `nil`, but subsequent steps will
# # still have access to `user:` and other kwargs
# end
#
module Tap
class TapStepAdapter < Dry::Transaction::StepAdapters
include Dry::Monads::Result::Mixin
def call(operation, _options, input)
result = operation.call(input[0])
return result if result.try(:failure?)
Success(input[0])
end
end
Dry::Transaction::StepAdapters.register(:tap, TapStepAdapter.new) unless Dry::Transaction::StepAdapters.key?(:tap)
end
end
end
end
# frozen_string_literal: true
module Tesseract
module Transaction
module Steps
# Adds a "use" step that will call other transactions. If that
# transaction returns a Hash, it will be merged with the input hash and
# returned, otherwise the step returns the result of the transaction. If
# the transaction results in a Failure, that failure is returned.
#
# Usage:
#
# use MyTransaction
#
module Use
extend ActiveSupport::Concern
module ClassMethods
def use(transaction, **kwargs)
method_name = (transaction.is_a?(Class) ? transaction : transaction.class).name.intern
step method_name
define_method method_name do |params|
params.merge!(kwargs)
result = transaction.call(params)
result.fmap { |value| value.is_a?(Hash) ? params.merge(value) : params }
end
end
end
end
end
end
end
# frozen_string_literal: true
module Tesseract
module Transaction
module Steps
# Adds a "validation" step that expects the method to return a
# Dry::Validation validator. It runs the validator on the input
# arguments, and returns Success on the validation output when the
# validator passes, or Failure with the result containing the validation
# errors.
#
# Also adds a DSL method `validate` that allows you do define the
# validator inline and will then run it as the step.
#
# Usage with an explicit validation step:
#
# valid :my_validation
# step :next_thing
#
# def my_validation(params)
# Dry::Validation.Params do
# require(:name).filled
# optional(:age).maybe(:int?)
# end
# end
#
# def next_thing(name:, age:)
# end
#
# Usage with an implicit validator:
#
# validate do
# require(:name).filled
# optional(:age).maybe(:int?)
# end
#
# step :next_thing
#
# def next_thing(name:, age:)
# end
#
module Validation
extend ActiveSupport::Concern
included do |base|
base.send :extend, Dry::Core::ClassAttributes
base.defines :validator
end
module ClassMethods
def validate(&block)
validator(Dry::Validation.Params(&block))
valid :validate
define_method :validate do |params|
self.class.validator.call(params)
end
end
end
class ValidationStepAdapter < Dry::Transaction::StepAdapters
include Dry::Monads::Result::Mixin
def call(operation, _options, input)
result = operation.call(input[0])
if result.success?
Success(result.output)
else
Failure(result)
end
end
end
Dry::Transaction::StepAdapters.register(:valid, ValidationStepAdapter.new) unless Dry::Transaction::StepAdapters.key?(:valid)
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.