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