Created
January 5, 2017 05:29
-
-
Save bjeanes/dc0a3dde74c0dcccfe6b65834cfc7cd4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# | |
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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