Skip to content

Instantly share code, notes, and snippets.

@bjeanes
Created January 5, 2017 05:29
Show Gist options
  • Save bjeanes/dc0a3dde74c0dcccfe6b65834cfc7cd4 to your computer and use it in GitHub Desktop.
Save bjeanes/dc0a3dde74c0dcccfe6b65834cfc7cd4 to your computer and use it in GitHub Desktop.
require 'command/result'
require 'command/result/switch'
module Command
def self.included(klass)
klass.extend Command::ClassMethods
end
module ClassMethods
def call(**options, &block)
handle = block_given? ?
Result::Switch.new(&block) :
->(result) { result }
code, result = catch(:err) do
begin
[:ok, new(**options).call]
rescue => e
if block_given?
# Let the switcher provide an avenue for handling this error. If it
# doesn't, it will re-raise anyway.
throw :err, [:exception, e]
else
raise
end
end
end
if code == :ok
handle.(Success.new(result))
elsif code == :exception
handle.(Failure.new(code: code, cause: result))
else
handle.(Failure.new(code: code, payload: result))
end
end
end
def initialize(**options)
end
def call
# Implement me
end
private
# A transaction helper which _defaults_ to creating a nested transaction
# (unlike ActiveRecord) and rolls back when an err result is returned.
def transaction(requires_new: true, &block)
err = nil
ActiveRecord::Base.transaction(requires_new: requires_new) do
err = catch(:err) { return block.call }
raise ActiveRecord::Rollback
end
throw :err, err
end
def err!(code = :error, value = nil)
throw :err, [code, value]
end
end
#
# Ideally this `Result` concept is top-level and not at all `Command`-specific,
# but `Result` is already a ActiveRecord model in this app so the constant is
# taken. Eventually, the model concept might have a better name and this one
# can be promoted to the top-level.
#
module Command
# `Result` is included into `Success` and `Failure`.
#
# Defines the common interface and allows both result types to register as a
# `Result` with `#is_a?`.
#
# Not a superclass, because `Failure` needs to inherit from an exception to be
# `raise`-able.
module Result
def success?
false
end
def failure?
false
end
def map(&block)
raise NotImplementedError
end
def value
raise NotImplementedError
end
def inspect
"#<Result>"
end
end
# `Success` wraps the successful outcome value of some operation.
#
# The value can be accessed with `#value` or by block with `#map`.
class Success
include Result
attr_reader :value
def initialize(value = nil)
@value = value
end
def success?
true
end
def map(&block)
block.call(value)
end
def inspect
value = self.value.present? && " #{self.value.inspect}"
"#<Success#{value}>"
end
end
# `Failure` represents a failed outcome for some operation.
#
# It is also an exception so can be raised (e.g. when triggering an operation
# to return it's value directly.)
#
# Calling `#map` is a no-op (block is not called), so you can safely call
# `Result#map` regardless of the result type.
#
# Attempting to access the result value with `#value` will cause the `Failure`
# to `raise` itself.
class Failure < RuntimeError
include Result
attr_reader :code, :payload
def initialize(code: :error, payload: {}, cause: nil, message: nil, i18n: I18n)
@code, @payload, @cause = code, payload, cause
message ||= i18n.translate(code, {
locale: :en,
scope: [:errors],
**(payload.is_a?(Hash) ? payload : {})
})
super(message)
end
def failure?
true
end
def map
# no-op
end
# :nodoc:
#
# We let the cause be explicitly passed in here so that we can wrap an
# exception in a failure without raising it. Ruby only sets an exception's
# cause when it is `raise`d and existing exception is in scope in `$!`:
#
# begin
# raise
# rescue
# Failure.new
# end.cause # => nil
#
# begin
# raise
# rescue
# begin
# raise Failure
# rescue => e
# e
# end
# end.cause # => RuntimeError
#
def cause
@cause || super
end
def value
if cause
raise cause
else
raise self
end
end
def inspect
"#<Failure code=#{code}>"
end
end
end
require 'spec_helper'
RSpec.describe Command::Result do
describe Command::Success do
it 'is a Result' do
expect(Command::Success.new).to be_a Command::Result
end
it 'is a success' do
expect(Command::Success.new).to be_success
end
it 'is not a failure' do
expect(Command::Success.new).to_not be_failure
end
it 'can have no value' do
expect(Command::Success.new.value).to be_nil
end
it 'has a value' do
expect(Command::Success.new(42).value).to eq 42
end
it 'is mappable' do
result = Command::Success.new(42)
expect { |b| result.map(&b) }.to yield_with_args(42)
end
end
describe Command::Failure do
it 'is a failure' do
expect(Command::Failure.new).to be_failure
end
it 'is not a success' do
expect(Command::Failure.new).to_not be_success
end
it 'has a default payload' do
expect(Command::Failure.new.payload).to eq({})
end
it 'has a custom payload' do
expect(Command::Failure.new(payload: 42).payload).to eq 42
end
it 'is does nothing when being mapped' do
result = Command::Failure.new(payload: 42)
expect { |b| result.map(&b) }.not_to yield_control
end
it 'raises itself if trying to access the result value' do
result = Command::Failure.new
expect { result.value }.to raise_error(result)
end
it 'has a default error code' do
expect(Command::Failure.new.code).to eq :error
end
it 'has an error code' do
expect(Command::Failure.new(code: :foo).code).to eq :foo
end
it 'accepts a custom message' do
expect(Command::Failure.new(message: 'xyz').message).to eq 'xyz'
end
it 'looks up error messages based on code' do
code = :foo
i18n = class_double(I18n)
allow(i18n).to receive(:translate).with(code, {locale: :en, scope: [:errors]}) do
"My error message"
end
expect(Command::Failure.new(code: code, i18n: i18n).message).
to eq "My error message"
end
it 'interpolates payload in custom messages' do
i18n = class_double(I18n)
payload = {foo: 1, bar: 2}
expect(i18n).to receive(:translate).with(anything, hash_including(payload))
Command::Failure.new(payload: payload, i18n: i18n).message
end
end
end
module Command
module Result
# An object which is initialized with a block that defines callbacks to be
# used based on a certain Result
#
# Then, passed a result, it will invoke the correct callback.
#
# result_handler = Command::Result::Switch.new do
# ok do |value|
# # Called on success result
# end
#
# error(:some_code) do |payload|
# # Called when specific error occurs
# end
#
# error do |code, payload|
# # Called on any kind of declared error
# end
#
# exception(SomeError) do |e|
# # Called for any SomeError (or sub-class) raised
# # during command
# end
#
# exception do |e|
# # Same as above, but implicitly for StandardError
# end
#
# # If no exception handler for a raised exception is
# # found, it is re-raised to the caller of the command
#
# any do |result|
# # Called for any success or failure, IFF another
# # callback didn't match
# #
# # NOT called for unhandled exceptions (they are re-raised)
# #
# # Preferably, just call `Result#map` instead of using this if its
# # the only handler you're defining.
# end
# end
#
# result_handler.switch(some_result) # Calls correct handler
#
# Exactly one callback will be called (the most specific) or an exception
# will be raised.
#
# The scope inside the handler definition is very minimal but the scope
# inside the handler blocks themselves will be the same as the caller (e.g.
# a controller)
#
class Switch
def initialize(&handler_definitions)
raise ArgumentError, 'block required' unless block_given?
# Capture the scope of the caller, so it can be applied to handler blocks
@caller = handler_definitions.binding.eval("self")
@handlers = define_handlers(handler_definitions)
end
def call(result)
if result.success?
handle_success(result)
else
handle_failure(result)
end
end
private
# Either calls the `ok` handler or the fallback.
#
# Raises if neither is defined.
#
def handle_success(result)
if (handler = @handlers[:ok])
@caller.instance_exec(result.value, &handler)
elsif (handler = @handlers[:fallback])
@caller.instance_exec(result, &handler)
else
raise ArgumentError, "No success handler or fallback defined"
end
end
# Either calls the `error(code)`, `error`, or the fallback.
#
# Raises if none of the above is defined.
#
# If the failure is due to an unexpected exception, it delegates to the
# exception handler.
def handle_failure(result)
return handle_exception(result) if result.code == :exception
if (handler = @handlers[:error][result.code])
@caller.instance_exec(result.payload, &handler)
elsif (handler = @handlers[:error][:fallback])
@caller.instance_exec(result.code, result.payload, &handler)
elsif (handler = @handlers[:fallback])
@caller.instance_exec(result, &handler)
else
raise ArgumentError, "No failure handler or fallback defined"
end
end
# Calls the most-specific `exception` handler that specifies the
# exception class or one of it's ancestors
#
# If no ancestor handler (including the generic `exception` handler) is
# found, then it re-raises the original exception.
#
def handle_exception(result)
exception = result.cause
handlers = @handlers[:exception]
ancestors = exception.class.ancestors.
select { |klass| klass.ancestors.include?(::Exception) }
ancestors.each do |klass|
if handlers.key?(klass)
handler = handlers[klass]
return @caller.instance_exec(exception, &handler)
end
end
raise exception
end
# Creates a disposable binding for defining result handlers and invokes
# the `Switch`'s definition block in its context.'
#
# Returns the `Hash` of handlers.
#
def define_handlers(definitions)
handlers = {
error: {},
exception: {},
}
# This is a bit "meta" because we want the block definition to be able
# to call methods like `ok`, `error`, but those methods need to close over
# the handlers hash (above) in order to add to it. If it were a normal
# class, we'd have to expose access to the handlers to outside, which
# defeats the purpose of using a BasicObject here so the handler
# definition block has a very small interface.
binding = Class.new(BasicObject) do
define_method(:ok) do |&handler|
::Kernel.raise ::ArgumentError, "No block given" unless handler
handlers[:ok] = handler
end
define_method(:error) do |code=nil, &handler|
::Kernel.raise ::ArgumentError, "No block given" unless handler
handlers[:error][code || :fallback] = handler
end
define_method(:exception) do |klass=StandardError, &handler|
::Kernel.raise ::ArgumentError, "No block given" unless handler
handlers[:exception][klass] = handler
end
define_method(:any) do |&handler|
::Kernel.raise ::ArgumentError, "No block given" unless handler
handlers[:fallback] = handler
end
end.new
# Run the passed in definition block in the context of our magic
# definer binding, which will update the handlers hash.
binding.instance_exec(binding, &definitions)
if handlers.values.all?(&:blank?)
raise ArgumentError, "No handlers defined"
end
# This hash has now been with the handler callbacks needed to handler a
# result later.
handlers
end
end
end
end
require 'spec_helper'
RSpec.describe Command::Result::Switch do
it 'errors without a block' do
expect { described_class.new }.to raise_error(ArgumentError, 'block required')
end
it 'raises error if block not passed to handlers' do
aggregate_failures do
expect { described_class.new { ok } }.to raise_error(ArgumentError, 'No block given')
expect { described_class.new { error } }.to raise_error(ArgumentError, 'No block given')
expect { described_class.new { error(:boom!) } }.to raise_error(ArgumentError, 'No block given')
expect { described_class.new { exception } }.to raise_error(ArgumentError, 'No block given')
expect { described_class.new { exception(RuntimeError) } }.to raise_error(ArgumentError, 'No block given')
expect { described_class.new { any } }.to raise_error(ArgumentError, 'No block given')
end
end
it 'errors if no handlers defined' do
expect { described_class.new { } }.to raise_error(ArgumentError, 'No handlers defined')
end
describe 'handler scope' do
let(:result) { Command::Success.new }
def method_on_caller
@called = true
end
it "has access to call methods from the caller" do
switch { ok { method_on_caller }}
expect(@called).to be true
end
it "has access to caller's instance variables" do
switch { ok { @called = true }}
expect(@called).to be true
end
end
context 'wtith successful result' do
let(:result) { Command::Success.new(42) }
it 'calls the OK block with its value' do
expect do |b|
switch do
ok(&b)
any { fail "fallback incorrectly invoked" }
end
end.to yield_with_args(result.value)
end
it 'calls the fallback with result' do
expect do |b|
switch { any(&b) }
end.to yield_with_args(result)
end
it 'raises if no OK handler or fallback is defined' do
expect do
switch do
error { } # handlers defined, but not OK or fallback
end
end.to raise_error(ArgumentError, 'No success handler or fallback defined')
end
end
context 'with failure result' do
let(:result) { Command::Failure.new(code: :boom!) }
it 'calls the general error block on failure with default error code and empty payload' do
expect do |b|
switch(Command::Failure.new) do
error(&b)
any { fail "fallback incorrectly invoked" }
end
end.to yield_with_args(:error, {})
end
it 'passes error code and payload to general error block' do
expect do |b|
switch(Command::Failure.new(code: :boom!, payload: {a: 42})) do
error(&b)
any { fail "fallback incorrectly invoked" }
end
end.to yield_with_args(:boom!, {a: 42})
end
it 'calls the specific error block on failure' do
expect do |b|
switch do
error(:boom!, &b)
error { fail "general error handler invoked" }
any { fail "fallback incorrectly invoked" }
end
end.to yield_control
end
it 'passes payload to specific error block' do
expect do |b|
switch(Command::Failure.new(code: :boom!, payload: {a: 42})) do
error(:boom!, &b)
error { fail "general error handler invoked" }
any { fail "fallback incorrectly invoked" }
end
end.to yield_with_args({a: 42})
end
it 'calls the fallback with result' do
expect do |b|
switch { any(&b) }
end.to yield_with_args(result)
end
it 'raises if no error handlers or fallback is defined' do
expect do
switch do
ok { } # handlers defined, but not errors or fallback
end
end.to raise_error(ArgumentError, 'No failure handler or fallback defined')
end
end
context 'with exception result' do
let(:exception_class) { Class.new(RuntimeError) }
let(:exception) { exception_class.new('error') }
let(:result) { Command::Failure.new(code: :exception, cause: exception)}
it 'calls the general exception handler with exception' do
expect do |b|
switch { exception(&b) }
end.to yield_with_args(exception)
end
it 'calls the specific exception handler with exception' do
klass = exception_class
expect do |b|
switch do
exception { fail "general exception handler invoked" }
exception(klass, &b)
end
end.to yield_with_args(exception)
end
it 'calls an ancestor exception handler with exception' do
expect do |b|
switch do
exception { fail "general exception handler invoked" }
exception(StandardError) { fail "too general exception ancestor handler invoked" }
exception(RuntimeError, &b)
end
end.to yield_with_args(exception)
end
it 'does not call the fallback handler' do
expect do |b|
begin
switch do
any(&b)
end
rescue
end
end.not_to yield_control
end
it 'does not call the error handler' do
expect do |b|
begin
switch do
error(&b)
end
rescue
end
end.not_to yield_control
end
it 're-raises the original error if handler not defined' do
expect do
switch do
# handlers defined, just not exception handlers
ok { }
error { }
end
end.to raise_error(exception)
end
end
private
def switch(result = self.result, &block)
described_class.new(&block).call(result)
end
end
require 'spec_helper'
RSpec.describe Command do
describe 'subclasses' do
context 'called directly' do
it 'returns a successful result' do
result = command { 42 }.call
aggregate_failures do
expect(result).to be_a Command::Result
expect(result).to be_a Command::Success
expect(result).to be_success
expect(result.value).to eq 42
end
end
it 'returns a generic failure result' do
result = command { err! }.call
aggregate_failures do
expect(result).to be_a Command::Result
expect(result).to be_a Command::Failure
expect(result).to be_failure
expect { result.value }.to raise_error(result)
expect(result.payload).to be_nil
expect(result.code).to eq :error
end
end
it 'returns a named failure result' do
result = command { err! :validation_failed }.call
aggregate_failures do
expect(result).to be_a Command::Result
expect(result).to be_a Command::Failure
expect(result).to be_failure
expect { result.value }.to raise_error(result)
expect(result.payload).to be_nil
expect(result.code).to eq :validation_failed
end
end
it 'returns a named failure result with a value' do
result = command { err! :validation_failed, ["error1", "error2"] }.call
aggregate_failures do
expect(result).to be_a Command::Result
expect(result).to be_a Command::Failure
expect(result).to be_failure
expect(result.payload).to eq ["error1", "error2"]
expect(result.code).to eq :validation_failed
end
end
it 'raises on exception' do
cmd = command { raise ArgumentError, "Missing :foo" }
expect { cmd.call }.to raise_error(ArgumentError, "Missing :foo")
end
it 'rolls back transactions on error' do
cmd = command do
transaction do
Plan.create!(name: SecureRandom.hex, price_cents: 0, number_of_reviews: 0)
err!
end
end
expect { cmd.call }.not_to change { Plan.count }
end
end
context 'called with switch block' do
it 'handles a successful result with :ok switch' do
cmd = command { 42 }
yielded = nil
cmd.call do
error { fail "error handler called when it shouldn't have been" }
ok do |result|
yielded = result
end
end
expect(yielded).to eq 42
end
it 'allows setting ivars in handler' do
cmd = command { 42 }
cmd.call do
ok do |result|
@yielded = result
end
end
expect(@yielded).to eq 42
end
it 'raises ArgumentError if no successful result handler is defined for a successful result' do
cmd = command { 42 }
expect {
cmd.call do
error(:foobar) {}
end
}.to raise_error(ArgumentError, 'No success handler or fallback defined')
end
it 'handles named error' do
cmd = command { err! :validation_failed, ["error1", "error2"] }
errors = nil
cmd.call do
ok { raise "success handler called when it shouldn't have been" }
error(:validation_failed) { |e| errors = e }
end
expect(errors).to eq ["error1", "error2"]
end
it 'handles exceptions generically' do
cmd = command { raise ArgumentError, "Missing :foo" }
exception = nil
cmd.call do
exception { |e| exception = e }
end
expect(exception).to be_a ArgumentError
end
it 'handles exceptions by class' do
cmd = command { raise ArgumentError, "Missing :foo" }
exception = nil
cmd.call do
exception(ArgumentError) { |e| exception = e }
exception { |e| raise "I should not be caused" }
end
expect(exception).to be_a ArgumentError
end
it 'handles exceptions by ancestor' do
cmd = command { raise ArgumentError, "Missing :foo"}
exception = nil
cmd.call do
exception(StandardError) { |e| exception = e }
exception(Exception) { fail "exception ancestor handler called when more specific one exists" }
end
expect(exception).to be_a ArgumentError
end
it 're-raises unhandled exception' do
cmd = command { raise ArgumentError, "Missing :foo"}
expect {
cmd.call do
ok { } # No exception handler
end
}.to raise_error(ArgumentError, "Missing :foo")
end
end
end
def command(&block)
Class.new do
include Command
define_method(:call, &block)
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment