Skip to content

Instantly share code, notes, and snippets.

@paul
Created December 25, 2018 04:05
Show Gist options
  • Save paul/b29b366d87880f91d8bb881dedddfcdb to your computer and use it in GitHub Desktop.
Save paul/b29b366d87880f91d8bb881dedddfcdb to your computer and use it in GitHub Desktop.
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