Rung
Rung is service object/business operation/Railway DSL.
This is a lightweight, independent alternative to Trailblazer Operation and dry-transaction.
Installation
Add this line to your application’s Gemfile:
gem 'rung'
And then execute:
$ bundle
Or install it yourself as:
$ gem install rung
Example Usage
Example:
class CreateOrder < Rung::Operation
step do |state|
state[:order_id] = "order-#{SecureRandom.uuid }"
end
step ValidateMagazineState
step :log_start
step WithBenchmark do
step CreateTemporaryOrder
step :place_order
end
step :log_success
failure :log_failure
def log_start(state)
state[:logger].log("Creating order #{state[:order_id]}")
end
def log_success(state)
state[:logger].log("Order #{state[:order_id]} created successfully")
end
def log_failure(state)
state[:logger].log("Order #{state[:order_id]} not created")
end
def place_order(state)
status = OrdersRepository.create(state[:order_id])
# Step return value is important.
# If step returns falsy value then the operation is considered as a failure.
status == :success
end
end
result = CreateOrder.call(logger: Rails.logger)
if result.success?
print "Created order #{result[:order_id]}"
end
Docs
Docs: https://gogiel.github.io/rung/ Generated from Cucumber specifications using Cukedoctor.
YARD docs: https://www.rubydoc.info/github/gogiel/rung.
Cucumber docs can be generated locally using $ rake docker_generate_docs. It requires Docker.
Development
After checking out the repo, run bundle to install dependencies. Then, run rake to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/gogiel/rung.
License
The gem is available as open source under the terms of the MIT License.
Operation definition
Steps can be defined as a Ruby block
- Given
-
definition
class Operation < Rung::Operation
step do |state|
state[:what] = "World"
end
step do
print_to_output "Hello "
end
step do |state|
print_to_output state[:what]
end
step do
print_to_output "!"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello World!
Steps can be defined as methods
- Given
-
definition
class Operation < Rung::Operation
step :set_what_state
step :print_hello
step "print_what"
step :print_bang
def set_what_state(state)
state[:what] = "World"
end
def print_hello
print_to_output "Hello "
end
def print_what(state)
print_to_output state[:what]
end
def print_bang
print_to_output "!"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello World!
Steps can be defined as any objects with call method
- Given
-
definition
class SetWhatState
def initialize(what)
@what = what
end
def call(state)
state[:what] = @what
end
end
class PrintHello
def self.call
print_to_output "Hello "
end
end
class PrintWhat
def self.call(state)
print_to_output state[:what]
end
end
PrintBang = -> { print_to_output "!" }
class Operation < Rung::Operation
step SetWhatState.new("World")
step PrintHello
step PrintWhat
step PrintBang
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello World!
State
State is shared across step executions
- Given
-
definition
class Operation < Rung::Operation
step do |state|
state[:what] = "World!"
end
step do
print_to_output "Hello "
end
step do |state|
print_to_output state[:what]
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello World!
State is available in the result object
- Given
-
definition
class Operation < Rung::Operation
step do |state|
state[:output_text] = "Hello "
end
step do |state|
state[:output_text] << "World!"
end
end
- When
-
I run
@result = Operation.new.call
- Then
-
I can assure that
@result[:output_text] == "Hello World!"
Initial state can be passed to call method
- Given
-
definition
class Operation < Rung::Operation
step do |state|
state[:output_text] << "World!"
end
end
- When
-
I run
@result = Operation.new.call(output_text: "Hello ")
- Then
-
I can assure that
@result[:output_text] == "Hello World!"
- And
-
I can assure that
@result.to_h == { output_text: "Hello World!" }
Success and failure
When all steps return truthy value the result is a success
- Given
-
definition
class Operation < Rung::Operation
step do
# do something...
true
end
step :second_step
def second_step
2 + 2
end
end
- When
-
I run
@result = Operation.new.call
- Then
-
I can assure that
@result.success? == true
- And
-
I can assure that
@result.failure? == false
- And
-
I can assure that
fail?alias works
@result.fail? == false
- And
-
I can assure that
failed?alias works
@result.failed? == false
When at least one step returns a falsy value then the result is a failure
- Given
-
definition
class Operation < Rung::Operation
step do
# do something...
true
end
step :second_step
def second_step
nil
end
end
- When
-
I run
@result = Operation.new.call
- Then
-
I can assure that
@result.success? == false
- And
-
I can assure that
@result.failure? == true
Failure Step
Failure step is executed when operation fails
- Given
-
definition
class Operation < Rung::Operation
step do
print_to_output "Working..."
# something went wrong...
false
end
failure do
print_to_output " Oops!"
end
step do
print_to_output "This won't be executed"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Working... Oops!
Failure step is not executed when operation doesn’t fail
- Given
-
definition
class Operation < Rung::Operation
step do
print_to_output "Working..."
# everything's fine
true
end
failure do
print_to_output " Oops!"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Working...
It’s possible to define multiple failure steps
- Given
-
definition
class Operation < Rung::Operation
step do |state|
print_to_output "Working..."
# something went wrong...
state[:error] = "404"
false
end
failure do
print_to_output " Error: "
end
failure do |state|
print_to_output state[:error]
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Working... Error: 404
Failure step is executed only when it’s defined after failed step
- Given
-
definition
class Operation < Rung::Operation
failure do
print_to_output "Something's wrong"
end
step do
print_to_output "Working..."
# something went wrong...
false
end
failure do
print_to_output " Oops!"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Working... Oops!
Other steps
tee step result is not checked
- Given
-
definition
class Operation < Rung::Operation
tee do
print_to_output "Hello "
false
end
step do
print_to_output "World!"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello World!
tee is not executed when operation is failed
- Given
-
definition
class Operation < Rung::Operation
step do
print_to_output "Hello"
false # fail
end
tee do
print_to_output "World!"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello
always is called when operation is successful
- Given
-
definition
class Operation < Rung::Operation
step do
print_to_output "Hello "
end
always do
print_to_output "World!"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello World!
always is called when operation is failed
- Given
-
definition
class Operation < Rung::Operation
step do
print_to_output "Hello "
false # fail
end
always do
print_to_output "World!"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello World!
state object provides information about operation success with success? and fail?
- Given
-
definition
class Operation < Rung::Operation
step do
print_to_output "Hello"
false # fail
end
always do |state|
print_to_output " World!" if state.success?
print_to_output " There!" if state.fail?
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello There!
Fail fast
When step with fail_fast fails the execution is immediately stopped
- Given
-
definition
class Operation < Rung::Operation
step(fail_fast: true) do
print_to_output "Hello"
false
end
step do
print_to_output " World!"
end
end
- When
-
I run
@result = Operation.new.call
- Then
-
I see output
Hello
- And
-
I can assure that
@result.failure?
Any kind of step can use fail_fast
- Given
-
definition
class Operation < Rung::Operation
step do
print_to_output "Hello"
false
end
failure fail_fast: true do
print_to_output "...Goodbye!"
end
step do
print_to_output " World!"
end
end
- When
-
I run
@result = Operation.new.call
- Then
-
I see output
Hello...Goodbye!
- And
-
I can assure that
@result.failure?
Nested operations
Nested Operation can be executed using nested helper
Current state is passed to the nested step. Operation output is merged to the caller state.
- Given
-
definition
class InnerOperation < Rung::Operation
step do |state|
state[:output] = state[:input] + 1
end
end
class Operation < Rung::Operation
step nested(InnerOperation)
end
- When
-
I run
@result = Operation.call(input: 1)
- Then
-
I can assure that
@result[:output] == 2
Nested Operation failure is treated as a step failure
- Given
-
definition
class InnerOperation < Rung::Operation
step do
false # fail
end
end
class Operation < Rung::Operation
step nested(InnerOperation)
end
- When
-
I run
@result = Operation.call(input: 1)
- Then
-
I can assure that
@result.failure?
Nested Operation input and output can be re-mapped
- Given
-
definition
class InnerOperation < Rung::Operation
step do |state|
state[:output] = state[:input] + 1
end
end
class Operation < Rung::Operation
step nested(InnerOperation,
input: ->(state) { {input: state[:value] } },
output: ->(state) { { calculated: state[:output] } }
)
step do |state|
state[:sum] = state[:calculated] + state[:value]
end
end
- When
-
I run
@result = Operation.call(value: 1)
- Then
-
I can assure that
@result.to_h == { calculated: 2, value: 1, sum: 3 }
Step wrappers
Wrapper can yield to execute inner steps
- Given
-
definition
class Operation < Rung::Operation
class Wrapper
def self.call
print_to_output "Starting\n"
success = yield
if success
print_to_output "\nSuccess!"
else
print_to_output "\nFailure!"
end
end
end
step Wrapper do
step { print_to_output "Hello " }
step do |state|
print_to_output "World!"
state[:variable] # return variable passed by the user
end
end
end
- When
-
I run
Operation.new.call(variable: true)
- Then
-
I see output
Starting Hello World! Success!
- Then
-
I clear output
- When
-
I run
Operation.new.call(variable: false)
- Then
-
I see output
Starting Hello World! Failure!
Wrappers can be nested
- Given
-
definition
class Operation < Rung::Operation
class Wrapper
def initialize(name)
@name = name
end
def call
print_to_output "Starting #{@name}\n"
yield
print_to_output "Finishing #{@name}\n"
end
end
step Wrapper.new("first") do
step { print_to_output "Hello\n" }
step Wrapper.new("second") do
step { print_to_output "Hi\n" }
end
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Starting first Hello Starting second Hi Finishing second Finishing first
step type is important when calling a wrapper
- Given
-
definition
class Operation < Rung::Operation
class Wrapper
def initialize(name)
@name = name
end
def call
print_to_output "from: #{@name}\n"
yield
end
end
step do
print_to_output "RED ALERT\n"
false
end
step Wrapper.new("OK") do
step { print_to_output "Hurray!\n" }
end
failure Wrapper.new("FAIL") do
failure { print_to_output "Oops!\n" }
end
always Wrapper.new("ALWAYS") do
step { print_to_output "We're done!\n" }
failure { print_to_output "We're done, but something went wrong!\n" }
end
end
- When
-
I run
Operation.new.call(variable: true)
- Then
-
I see output
RED ALERT from: FAIL Oops! from: ALWAYS We're done, but something went wrong!
Operation wrappers
Operation can have multiple global wrappers
Wrappers are called in the order they are defined.
- Given
-
definition
class Operation < Rung::Operation
class Wrapper
def initialize(name)
@name = name
end
def call
print_to_output "#{@name} start\n"
yield
print_to_output "#{@name} done\n"
end
end
around Wrapper.new("1")
around Wrapper.new("2")
step { print_to_output "Hello World!\n" }
end
- When
-
I run
Operation.new.call(variable: true)
- Then
-
I see output
1 start 2 start Hello World! 2 done 1 done
Exceptions handling
All exceptions are raised
- Given
-
definition
class Operation < Rung::Operation
step { raise "Oh no!" }
end
- When
-
I run
begin Operation.new.call(output_text: "Hello ") rescue => e print_to_output e.message end
- Then
-
I see output
Oh no!
Exception can be caught in a wrapper
- Given
-
definition
class Operation < Rung::Operation
class Wrapper
def self.call(state)
yield
rescue
print_to_output "Exception handled"
state[:exception_handled] = true
end
end
around Wrapper
step { print_to_output "Hello World!\n"; raise "oops!" }
end
- When
-
I run
@result = Operation.new.call
- Then
-
I see output
Hello World! Exception handled
- And
-
I can assure that
@result.to_h == { exception_handled: true }
Around step wrapper
Step wrapper is called for every step
- Given
-
definition
class Operation < Rung::Operation
class Counter
def initialize
@count = 0
end
def call
@count += 1
print_to_output "Step #{@count}\n"
yield
end
end
around_each Counter.new
step do
print_to_output "Hello\n"
end
step do
print_to_output "World\n"
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Step 1 Hello Step 2 World
Step wrapper receives state and current step
- Given
-
definition
class Operation < Rung::Operation
class Logger
def self.call(state, step)
result = yield
print_to_output "State: #{state.to_h}, success: #{state.success?}," \
"ignores result: #{step.ignore_result?}, nested: #{step.nested?}, result: #{result}\n"
result
end
end
noop_wrapper = -> (&block) { block.call }
around_each Logger
step do |state|
print_to_output "Hello\n"
state[:test] = 42
end
step noop_wrapper do
tee do |state|
state[:test] = 5
print_to_output "World\n"
true
end
end
end
- When
-
I run
Operation.new.call
- Then
-
I see output
Hello
State: {:test=>42}, success: true,ignores result: false, nested: false, result: 42
World
State: {:test=>5}, success: true,ignores result: true, nested: false, result: true
State: {:test=>5}, success: true,ignores result: false, nested: true, result: true
Step wrapper returned result is treated as the step result
- Given
-
definition
class Operation < Rung::Operation
class Boom
def self.call(state)
yield
print_to_output "Boom!\n"
false # always return false
end
end
around_each Boom
tee do |state| # tee step ignores result, so Boom doesn't affect it
print_to_output "Hello\n"
end
step { print_to_output "Beautiful\n" }
step { print_to_output "World\n" }
end
- When
-
I run
@result = Operation.new.call
- Then
-
I see output
Hello Boom! Beautiful Boom!
- And
-
I can assure that
@result.success? == false