Cuprum
A lightweight, functional-lite toolkit for making business logic a first-class citizen of your application.
Support
Cuprum is tested against Ruby 2.4.
Documentation
Method and class documentation is available courtesy of RubyDoc.
Documentation is generated using YARD, and can be generated locally using the yard
gem.
Contribute
GitHub
The canonical repository for this gem is located at https://github.com/sleepingkingstudios/cuprum.
A Note From The Developer
Hi, I'm Rob Smith, a Ruby Engineer and the developer of this library. I use these tools every day, but they're not just written for me. If you find this project helpful in your own work, or if you have any questions, suggestions or critiques, please feel free to get in touch! I can be reached on GitHub (see above, and feel encouraged to submit bug reports or merge requests there) or via email at [email protected]. I look forward to hearing from you!
Functions
Functions are the core feature of Cuprum. In a nutshell, each Cuprum::Function is a functional object that encapsulates a business logic operation. A Function provides a consistent interface and tracking of result value and status. This minimizes boilerplate and allows for interchangeability between different implementations or strategies for managing your data and processes.
Each Function implements a #call
method that wraps your defined business logic and returns an instance of Cuprum::Result. The result wraps the returned data (with the #value
method), any #errors
generated when running the Function, and the overall status with the #success?
and #failure
methods. For more details about Cuprum::Result, see below.
Methods
A Cuprum::Function defines the following methods:
#initialize
initialize { |*arguments, **keywords, &block| ... } #=> Cuprum::Function
Returns a new instance of Cuprum::Function. If a block is given, the #call
method will wrap the block and set the result #value
to the return value of the block. This overrides the implementation in #process
, if any.
#call
call(*arguments, **keywords) { ... } #=> Cuprum::Result
Executes the logic encoded in the constructor block, or the #process method if no block was passed to the constructor.
#chain
Registers a function or block to run after the current function, or after the last chained function if the current function already has one or more chained function(s). This creates and modifies a copy of the current function. See Chaining Functions, below.
chain(function, on: nil) #=> Cuprum::Function
The function will be passed the #value
of the previous function result as its parameter, and the result of the chained function will be returned (or passed to the next chained function, if any).
chain(on: nil) { |result| ... } #=> Cuprum::Function
The block will be passed the #result of the previous function as its parameter. If your use case depends on the status of the previous function or on any errors generated, use the block form of #chain.
If the block returns a Cuprum::Result (or an object responding to #value and #success?), the block result will be returned (or passed to the next chained function, if any). If the block returns any other value (including nil), the #result of the previous function will be returned or passed to the next function.
#then
Shorthand for function.chain(:on => :success)
. Registers a function or block to run after the current function. The chained function will only run if the previous function was successfully run.
then(function) #=> Cuprum::Function
The function will be passed the #value
of the previous function result as its parameter, and the result of the chained function will be returned (or passed to the next chained function, if any).
then() { |result| ... } #=> Cuprum::Function
The block will be passed the #result of the previous function as its parameter. If your use case depends on the status of the previous function or on any errors generated, use the block form of #chain.
If the block returns a Cuprum::Result (or an object responding to #value and #success?), the block result will be returned (or passed to the next chained function, if any). If the block returns any other value (including nil), the #result of the previous function will be returned or passed to the next function.
#else
Shorthand for function.chain(:on => :failure)
. Registers a function or block to run after the current function. The chained function will only run if the previous function was unsuccessfully run.
else(function) #=> Cuprum::Function
The function will be passed the #value
of the previous function result as its parameter, and the result of the chained function will be returned (or passed to the next chained function, if any).
else() { |result| ... } #=> Cuprum::Function
The block will be passed the #result of the previous function as its parameter. If your use case depends on the status of the previous function or on any errors generated, use the block form of #chain.
If the block returns a Cuprum::Result (or an object responding to #value and #success?), the block result will be returned (or passed to the next chained function, if any). If the block returns any other value (including nil), the #result of the previous function will be returned or passed to the next function.
Defining With a Block
Functions can be used right out of the box by passing a block to the Cuprum::Function constructor, as follows:
# A Function with a block
double_function = Cuprum::Function.new { |int| 2 * int }
result = double_function.call(5)
result.value #=> 10
The constructor block will be called each time Function#call
is executed, and will be passed all of the arguments given to #call
. You can even define a block parameter, which will be passed along to the constructor block when #call
is called with a block argument.
Defining With a Subclass
Larger applications will want to create Function subclasses that encapsulate their business logic in a reusable, composable fashion. The implementation for each subclass is handled by the #process
private method. If a subclass or its ancestors does not implement #process
, a Cuprum::Function::NotImplementedError
will be raised.
# A Function subclass
class MultiplyFunction < Cuprum::Function
def initialize multiplier
@multiplier = multiplier
end # constructor
private
def process int
int * @multiplier
end # method process
end # class
triple_function = MultiplyFunction.new(3)
result = triple_function.call(5)
result.value #=> 15
As with the block syntax, a Function whose implementation is defined via the #process
method will call #process
each time that #call
is executed, and will pass all arguments from #call
on to #process
. The value returned by #process
will be assigned to the result #value
.
Success, Failure, and Errors
Whether defined with a block or in the #process
method, the Function implementation can access an #errors
object while in the #call
method. Any errors added to the errors object will be exposed by the #errors
method on the result object.
# A Function with errors
class DivideFunction < Cuprum::Function
def initialize divisor
@divisor = divisor
end # constructor
private
def process int
if @divisor.zero?
errors << 'errors.messages.divide_by_zero'
return
end # if
int / @divisor
end # method process
end # class
In addition, the result object defines #success?
and #failure?
predicates. If the result has no errors, then #success?
will return true and #failure?
will return false.
halve_function = DivideFunction.new(2)
result = halve_function.call(10)
result.errors #=> []
result.success? #=> true
result.failure? #=> false
result.value #=> 5
If the result does have errors, #success?
will return false and #failure?
will return true.
function_with_errors = DivideFunction.new(0)
result = function_with_errors.call(10)
result.errors #=> ['errors.messages.divide_by_zero']
result.success? #=> false
result.failure? #=> true
result.value #=> nil
Chaining Functions
Because Cuprum::Function instances are proper objects, they can be composed like any other object. Cuprum::Function also defines methods for chaining functions together. When a chain of functions is called, each function in the chain is called in sequence and passed the value of the previous function. The result of the last function in the chain is returned from the chained call.
class AddFunction < Cuprum::Function
def initialize addend
@addend = addend
end # constructor
private
def process int
int + @addend
end # method process
end # class
double_and_add_one = MultiplyFunction.new(2).chain(AddFunction.new(1))
result = double_and_add_one(5)
result.value #=> 5
For finer control over the returned result, #chain
can instead be called with a block that yields the most recent result. If the block returns a Cuprum::Result, that result is returned or passed to the next function.
MultiplyFunction.new(3).
chain { |result| Cuprum::Result.new(result + 1) }.
call(3)
#=> Returns a Cuprum::Result with a value of 10.
Otherwise, the block is still called but the previous result is returned or passed to the next function in the chain.
AddFunction.new(2).
chain { |result| puts "There are #{result.value} lights!" }.
call(2)
#=> Writes "There are 4 lights!" to STDOUT.
#=> Returns a Cuprum::Result with a value of 4.
Conditional Chaining
The #chain
method can be passed an optional :on
keyword, with values of :success
and :failure
accepted. If #chain
is called with :on => :success
, then the chained function or block will only be called if the previous result #success?
returns true. Conversely, if #chain
is called with :on => :failure
, then the chained function will only be called if the previous result #failure?
returns true.
In either case, execution will then pass to the next function in the chain, which may itself be called or not if it was conditionally chained. Calling a conditional function chain will return the result of the last called function.
The methods #then
and #else
serve as shortcuts for #chain
with :on => :success
and :on => :failure
, respectively.
class EvenFunction < Cuprum::Function
private
def process int
errors << 'errors.messages.not_even' unless int.even?
int
end # method process
end # class
# The next step in a Collatz sequence is determined as follows:
# - If the number is even, divide it by 2.
# - If the number is odd, multiply it by 3 and add 1.
collatz_function =
EvenFunction.new.
then(DivideFunction.new(2)).
else(MultiplyFunction.new(3).chain(AddFunction.new(1)))
result = collatz_function.new(5)
result.value #=> 16
result = collatz_function.new(16)
result.value #=> 8
Operations
An Operation is like a Function, but with an additional trick of tracking its own most recent execution result. This allows us to simplify some conditional logic, especially boilerplate code used to interact with frameworks.
class CreateBookOperation < Cuprum::Operation
def process
# Implementation here.
end # method process
end # class
def create
operation = CreateBookOperation.new.call(book_params)
if operation.success?
redirect_to(operation.value)
else
@book = operation.value
render :new
end # if-else
end # create
Like a Function, an Operation can be defined directly by passing an implementation block to the constructor or by creating a subclass that overwrites the #process method.
An operation inherits the #call
method from Cuprum::Function (see above), and delegates the #value
, #errors
, #success?
, and #failure
methods to the most recent result (see below). If the operation has not been called, the operation will return default values.
Methods
A Cuprum::Operation inherits the methods from Cuprum::Function (see above), and defines the following additional methods:
#result
result() #=> Cuprum::Result
The most recent result, from the previous time #call
was executed for the operation.
#called?
called?() #=> true, false
True if the operation has been called and there is a result available by calling #result
or one of the delegated methods, otherwise false.
#reset!
reset!()
Clears the most recent result and resets #called?
to false. This frees the result and any linked data for garbage collection. It also clears any internal state from the operation.
Results
A Cuprum::Result is a data object that encapsulates the result of calling a Cuprum function - the returned value, the success or failure status, and any errors generated by the function.
value = 'A result value'.freeze
result = Cuprum::Result.new(value)
result.value
#=> 'A result value'
Methods
A Cuprum::Result defines the following methods:
#value
value() #=> Object
The value returned by the function. For example, for an increment function that added 1 to a given integer, the #value
of the result object would be the incremented integer.
#errors
errors() #=> Array
The errors generated by the function, or an empty array if no errors were generated.
#success?
success?() #=> true, false
True if the function did not generate any errors, otherwise false.
#failure?
failure?() #=> true, false
True if the function generated one or more errors, otherwise false.