An opinionated implementation of the Command pattern for Ruby applications. Cuprum wraps your business logic in a consistent, object-oriented interface and features status and error management, composability and control flow management.
A Matcher provides a simple DSL for defining behavior based on a Cuprum result object.
matcher = Cuprum::Matcher.new do
match(:failure) { 'Something went wrong' }
match(:success) { 'Ok' }
end
matcher.call(Cuprum::Result.new(status: :failure))
#=> 'Something went wrong'
matcher.call(Cuprum::Result.new(status: :success))
#=> 'Ok'
First, the matcher defines possible matches using the .match
method. This can either be called on a subclass of Cuprum::Matcher
or by passing a block to the constructor, as above. Each match clause must have the matching status, and a block that is executed when a result matches that clause. The clause can also filter by the result value or error (see Matching Values And Errors, below).
Once the matcher has found a matching clause, it then calls the block in the clause definition. If the block accepts an argument, the result is passed to the block; otherwise, the block is called with no arguments. This allows the match clause to use the error or value of the result.
matcher = Cuprum::Matcher.new do
match(:failure) { |result| result.error.message }
end
error = Cuprum::Error.new(message: 'An error has occurred.')
matcher.call(Cuprum::Result.new(error: error))
#=> 'An error has occurred.'
If the result does not match any of the clauses, a Cuprum::Matching::NoMatchError
is raised.
matcher = Cuprum::Matcher.new do
match(:success) { :ok }
end
matcher.call(Cuprum::Result.new(status: :failure))
#=> raises Cuprum::Matching::NoMatchError
In addition to a status, match clauses can specify the type of the value or error of a matching result. The error or value must be a Class or Module, and the clause will then match only results whose error or value is an instance of the specified Class or Module (or a subclass of the Class).
class MagicSmokeError < Cuprum::Error; end
matcher = Cuprum::Matcher.new do
match(:failure) { 'Something went wrong.' }
match(:failure, error: Cuprum::Error) do |result|
"ERROR: #{result.error.message}"
end
match(:failure, error: MagicSmokeError) do
"PANIC: #{result.error.message}"
end
end
matcher.call(Cuprum::Result.new(status: :failure))
#=> 'Something went wrong.'
error = Cuprum::Error.new(message: 'An error has occurred.')
matcher.call(Cuprum::Result.new(error: error)
#=> 'ERROR: An error has occurred.'
error = MagicSmokeError.new(message: 'The magic smoke is escaping.')
matcher.call(Cuprum::Result.new(error: error))
#=> 'PANIC: The magic smoke is escaping.'
The matcher will always apply the most specific match clause. In the example above, the result with a MagicSmokeError
matches all three clauses, but only the final clause is executed.
You can also specify the value of a matching result:
matcher = Cuprum::Matcher.new do
match(:success, value: String) { 'a String' }
match(:success, value: Symbol) { 'a Symbol' }
end
matcher.call(Cuprum::Result.new(value: 'Greetings, programs!'))
#=> 'a String'
matcher.call(Cuprum::Result.new(value: :greetings_starfighter))
#=> 'a Symbol'
Matcher classes allow you to define custom behavior that can be called as part of the defined match clauses.
class LogMatcher < Cuprum::Matcher
match(:failure) { |result| log(:error, result.error.message) }
match(:success) { log(:info, 'Ok') }
def log(level, message)
puts "#{level.upcase}: #{message}"
end
end
matcher = LogMatcher.new
matcher.call(Cuprum::Result.new(status: :success))
#=> prints "INFO: Ok" to STDOUT
Match clauses are also inherited by matcher subclasses. Inherited clauses are sorted the same as clauses defined on the matcher directly - the most specific clause is matched first, followed by less specific clauses and finally the generic clause (if any) for that result status.
class CustomLogMatcher < Cuprum::Matcher
match(:failure, error: ReallyBadError) do |result|
log(:fatal, result.error.message)
end
end
matcher = CustomLogMatcher.new
result = Cuprum::Result.new(error: Cuprum::Error.new('Something went wrong.'))
matcher.call(result)
#=> prints "ERROR: Something went wrong." to STDOUT
result = Cuprum::Result.new(error: ReallyBadError.new('Computer on fire.'))
matcher.call(result)
#=> prints "FATAL: Computer on fire." to STDOUT
Match contexts provide an alternative to defining custom matcher classes - instead of defining custom behavior in the matcher itself, the match clauses can be executed in the context of another object.
class Inflector
def capitalize(message)
message.split(' ').map(&:capitalize).join(' ')
end
end
matcher = Cuprum::Matcher.new(inflector) do
match(:success) { |result| capitalize(result.value) }
end
matcher.call(Cuprum::Result.new(value: 'greetings starfighter'))
#=> 'Greetings Starfighter'
For example, a controller in a web framework might need to define behavior for handling different success and error cases for business logic that is defined as Commands. The controller itself defines methods such as #render
and #redirect
- by creating a matcher using the controller as the match context, the matcher can call upon those methods to generate a response.
You can also call an existing matcher with a new context. The #with_context
method returns a copy of the matcher with the given object set as the match context.
matcher = Cuprum::Matcher.new do
match(:success) { |result| capitalize(result.value) }
end
matcher
.with_context(inflector)
.call(Cuprum::Result.new(value: 'greetings starfighter'))
#=> 'Greetings Starfighter'
Matcher lists handle matching a result against an ordered group of matchers.
When given a result, a matcher list will check for the most specific matching clause in each of the matchers. A clause matching both the value and error will match first, followed by a clause matching only the result value or error, and finally a clause matching only the result status will match.
If none of the matchers have a clause that matches the result, a Cuprum::Matching::NoMatchError
will be raised.
generic_matcher = Cuprum::Matcher.new do
match(:failure) { 'generic failure' }
#
match(:failure, error: CustomError) { 'custom failure' }
end
specific_matcher = Cuprum::Matcher.new do
match(:failure, error: Cuprum::Error) { 'specific failure' }
end
matcher_list = Cuprum::MatcherList.new(
[
specific_matcher,
generic_matcher
]
)
generic_matcher = Cuprum::Matcher.new do
match(:failure) { 'generic failure' }
match(:failure, error: CustomError) { 'custom failure' }
end
specific_matcher = Cuprum::Matcher.new do
match(:failure, error: Cuprum::Error) { 'specific failure' }
end
matcher_list = Cuprum::MatcherList.new(
[
specific_matcher,
generic_matcher
]
)
# A failure without an error does not match the first matcher, so the
# matcher list continues on to the next matcher in the list.
result = Cuprum::Result.new(status: :failure)
matcher_list.call(result)
#=> 'generic failure'
# A failure with an error matches the first matcher.
error = Cuprum::Error.new(message: 'Something went wrong.')
result = Cuprum::Result.new(error: error)
matcher_list.call(result)
#=> 'specific failure'
# A failure with an error subclass still matches the first matcher, even
# though the second matcher has a more exact match.
error = CustomError.new(message: 'The magic smoke is escaping.')
result = Cuprum::Result.new(error: error)
matcher_list.call(result)
#=> 'specific failure'
One use case for matcher lists would be in defining hierarchies of classes or objects that have matching functionality. For example, a generic controller class might define default success and failure behavior, an included mixin might provide handling for a particular scope of errors, and a specific controller might override the default behavior for a given action. Using a matcher list allows each class or module to define its own behavior as independent matchers, which the matcher list then composes together.
Back to Documentation