https://github.com/guzart/opie
Operations API for Railway oriented programming in Ruby
https://github.com/guzart/opie
operations railway-oriented-programming ruby transactions
Last synced: 12 months ago
JSON representation
Operations API for Railway oriented programming in Ruby
- Host: GitHub
- URL: https://github.com/guzart/opie
- Owner: guzart
- License: mit
- Created: 2017-03-18T22:54:47.000Z (over 9 years ago)
- Default Branch: master
- Last Pushed: 2018-01-27T03:48:39.000Z (over 8 years ago)
- Last Synced: 2025-04-04T14:01:56.390Z (over 1 year ago)
- Topics: operations, railway-oriented-programming, ruby, transactions
- Language: Ruby
- Homepage:
- Size: 39.1 KB
- Stars: 3
- Watchers: 1
- Forks: 0
- Open Issues: 3
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE.txt
Awesome Lists containing this project
README
# Opie
[](https://travis-ci.org/guzart/opie)
[](https://codecov.io/gh/guzart/opie)
[](https://codeclimate.com/github/guzart/opie)
[](https://badge.fury.io/rb/opie)
**Opie gives you a simple API for creating Operations using the
[Railsway oriented programming](https://vimeo.com/113707214) paradigm.**
## API
The `Opie::Operation` API:
* `::step(Symbol) -> void` indicates a method that is executed in the operation sequence
* `#success? -> Boolean` indicates whether the operation was successful
* `#failure? -> Boolean` indicates whether the operation was a failure
* `#failure -> Opie::Failure | nil` the failure if the operation is a `failure?`, nil when it's a success
* `#failures -> Array | nil` an array with all failures
* `#output -> * | nil` the operation's last step return value, nil when the operation fails
Internal API:
* `#step_name(Any, Any?) -> Any` the step signature. First argument is the input and the second argument is an optional context
* `#fail(error_type: Symbol, error_data: *) -> Opie::Failure` used inside the steps to indicate that the operation has failed
Executing an operation:
```ruby
input = { first_name: 'John', last_name: 'McClane', email: 'john@example.com' }
context = { current_user: 'admin' }
CreateUserOperation.(input, context)
```
_Tentative API_
* `::step(Array) -> void` a series of methods to be called in parallel
* `::step(Opie::Step) -> void` an enforcer of a step signature which helps to compose other steps
* `::failure(Symbol) -> void` indicates a custom method name to handle failures
## Usage
**Simple Usage:**
```ruby
# Create an Operation for completing a Todo
class Todos::CompleteTodo < Opie::Operation
step :find_todo
step :mark_as_complete
def find_todo(todo_id)
todo = Todo.find_by(id: todo_id)
return fail(:not_found, "Could not find the Todo using id: #{todo_id}") unless todo
todo
end
def mark_as_complete(todo)
success = todo.update(completed_at: Time.zone.now)
return fail(:update) unless success
todo
end
end
class TodosController < ApplicationController
def complete
# invoke the operation
result = Todos::CompleteTodo.(params[:id])
if result.success? # if #success?
render status: :created, json: result.output # use output
else
render status: :bad_request, json: { error: error_message(result.failure) } # otherwise use #failure
end
end
private
def error_message(failure)
case failure.type
when :not_found then failure.data
when :update then 'We were unable to make the changes to your todo'
else 'There was an unexpected error, sorry for the inconvenience'
end
end
end
```
**Real world example:**
Imagine yourself in the context of a [habit tracker](https://github.com/isoron/uhabits), wanting to
add a new habit to track.
```ruby
# app/controllers/habits_controller.rb
class HabitsController < ApplicationController
# POST /habits
def create
# run the `operation` – since it's a modification we can call it a `command`
result = People::AddHabit.(habit_params, operation_context)
# render response based on operation result
if result.success?
render status: :created, json: result.output
else
render_operation_failure(result.failure)
end
end
private
def render_operation_failure(failure)
render status: failure_http_status(failure.type), json: { errors: failure.data }
end
# the HTTP status depends on the error type, which separating the domain from the infrastructure
def failure_http_status(type)
case(type)
when :unauthorized then :unauthorized
when :validation then :unprocessable_entity
when :not_found then :not_found
else :server_error
end
end
# simulate parameters came from a Http request
def habit_params
{
person_id: 2,
name: 'Excercise',
description: 'Did you excercise for at least 15 minutes today?',
frequency: :three_times_per_week,
color: 'DeepPink'
}
end
def operation_context
{ current_user: current_user }
end
end
```
And now the code that defines the operation
```ruby
# application-wide dependencies container
class HabitTrackerContainer
extends Dry::Container::Mixin
register 'repositories.habit', HabitRepository.new
register 'repositories.person', PersonRepository.new
register 'service_bus', ServiceBus.new
end
# application-wide dependency injector
Import = Dry::AutoInject(HabitTrackerContainer.new)
module People
# we define a validation schema for our input
AddHabitSchema = Dry::Schema.Validation do
configure do
# custom predicate for frequency
def freq?(value)
[:weekly, :five_times_per_week, :four_times_per_week, :three_times_per_week].includes?(value)
end
end
required(:person_id).filled(:int?, gt?: 0)
required(:name).filled(:str?)
required(:description).maybe(:str?)
required(:frequency).filled(:freq?)
required(:color).filled(:str?)
end
# the operation logic starts
class AddHabit < Opie::Operation
# inject dependencies, more flexible than ruby's global namespace
include Import[
habit_repo: 'repositories.habit',
person_repo: 'repositories.person',
service_bus: 'service_bus'
]
# first step receives ::call first argument, then the output of the step is the argument of the next step
step :authorize
step :validate
step :find_person
step :persist_habit
step :send_event
def authorize(params, context)
# Authorize using Pundit's policy api
return fail(:unauthorized) if HabitPolicy.new(context, Habit).add?
params
end
# receives the first input
def validate(params)
schema = AddHabitSchema.(params)
return fail(:validation, schema.errors) if schema.failure?
schema.output
end
# if it's valid then find the person (tenant)
def find_person(params)
person = person_repo.find(params[:person_id])
return fail(:repository, 'We could not find your account') unless person
params.merge(person: person)
end
# persist the new habit
def persist_habit(params)
new_habit = Entities::Habit.new(params)
habit_repo.create(new_habit)
rescue => error
fail(:persist_failed, error.message)
end
# notify the world
def send_event(habit)
event = Habits::CreatedEvent.new(habit.attributes)
service_bus.send(event)
rescue => error
fail(:event_failed, error)
end
end
end
```
## Installation
Add this line to your application's Gemfile:
```ruby
gem 'opie'
```
And then execute:
```bash
$ bundle
```
Or install it yourself as:
```bash
$ gem install opie
```
## Development
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` 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](https://rubygems.org).
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/guzart/opie.
## License
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).