Liubov Derevianko, MobiDev
Trailblazer Stack Diagram
How that looks without Trailblazer
def create
@project = current_user.projects.new(project_params)
if @project.save
UserMailer.after_create_project_email(@project, current_user).deliver_later
redirect_to @project
else
render 'new'
end
end
def create
run Project::Create, params: params['project'].merge(current_user) do |op|
redirect_to @project
end
render 'new'
end
How that looks with Trailblazer
Example Operation
class Project < ApplicationRecord
class Create < Trailblazer::Operation
include Model
model Project, :create
contract do
property :user, virtual: true
property :title
property :budget
property :status
property :measurement_unit
validates :title, presence: true
end
def process(params)
validate(params[:project]) do |f|
f.save
end
end
end
end
How to call operations?
Run Style
res, operation = Comment::Create.run(
comment: {
body: "MVC is so 90s."
})
Returns a result set [result, operation] for both valid and invalid invocation.
How to call operations?
Run Style
operation = Comment::Create.run(comment: {}) do |op|
return "Hey, #{op.model} was created!" # not run.
end
puts "That's wrong: #{operation.errors}"
It also can accepts a block that’s run in case of a successful validation.
Only the operation instance is returned as the block represents a valid state.
Shorthand methods to run and present operations.
Use #present if you’re only interested in presenting the operation’s model.
Use #form to render the form object. This will not run the operation.
Use #run to process incoming data using the operation (and present it afterwards).
Use #respond to process incoming data and present it using Rails’ respond_to.
RUN
Invoke the operation
class CommentsController < ApplicationController
def create
run Comment::Create, params: params['comment']
end
end
- runs the operation with params
- sets @operation, @model and @form
- returns the operation instance.
RUN
An optional block is invoked only when the operation was valid.
class CommentsController < ApplicationController
def create
run Comment::Create do |op|
flash[:notice] = "Success!"
end
end
end
FORM
Render the form object. This will not run the operation.
class CommentsController < ApplicationController
def new
form Comment::Create
end
end
- instantiates the operation with params
- sets @operation, @model and @form
- does not run #process.
- returns the form instance.
Custom Params
If you want to manually hand in parameters to #run, #respond, #form or #present, use the params:option.
def create
run Comment::Create, params: {
comment: {
body: "Always the same! Boring!"
}
}
end
def create
run Comment::Create, params: params['comment'].merge(user: current_user) do |op|
.....
end
end
Operations Callstack
Here’s the default call stack of methods involved when running an Operation.
::call
├── ::build_operation
│ ├── #initialize
│ │ ├── #setup!
│ │ │ ├── #assign_params!
│ │ │ │ │ ├── #params! #Override if you want to use a different params hash.
│ │ │ ├── #setup_params! #Override to normalize the incoming params.
│ │ │ ├── #build_model!
│ │ │ │ ├── #assign_model!
│ │ │ │ │ ├── #model! #Override to compute the operation’s model.
│ │ │ │ ├── #setup_model! #Override for adding nested models to your model.
│ ├── #run
│ │ ├── #process # Implement for your business logic.
Contract
An operations lets you define a form object, or contract.
Internally, this simply creates a Reform form class.
Contract is like a schema of the data structure.
class Create < Trailblazer::Operation
contract do
property :user, virtual: true
property :title
property :budget
property :status
property :measurement_unit
validates :title, presence: true
end
def process(params)
# ..
end
end
Contract
Check out the #process method now.
class Create < Trailblazer::Operation
contract do
# ..
end
def process(params)
@model = Comment.new
validate(params[:comment]) do |f|
# process the data here
end
end
end
You don't need strong_parameters using operations and forms. The #validate method knows which parameters of the hash go into the form and what is to be ignored.
Contract Property
class Create < Trailblazer::Operation
contract do
property :password
property :password_confirmation, virtual: true
property :country, writeable: false
property :credit_card_number, readable: false
property :current_status, default: 'unknown'
end
end
Validation
class Create < Trailblazer::Operation
contract do
property :host
property :port
property :time_interval
property :slack_for_notifications
property :emails_for_notifications
validates :host, :port, :time_interval, presence: true
validates :time_interval,
numericality: {
only_integer: true,
greater_than_or_equal_to: MIN_PING_INTERVAL,
less_than_or_equal_to: MAX_PING_INTERVAL
}
end
end
Likewise, since Reform uses ActiveModel::Validations , you can use all kinds of standard validations like length or inclusion.
Validation
class Create < Trailblazer::Operation
contract do
property :slack_url
validates :slack_url,
url: true,
format: {
with: /^https:\/\/hooks\.slack\.com\/services/,
multiline: true,
message: '- Webhook url is invalid'
},
allow_blank: true
end
end
You can specify custom message for error in format block or allow this field to be blank
Validation
class Create < Trailblazer::Operation
contract do
property :emails_for_notifications
validates :emails_for_notifications, email: true
end
end
Custom validators are standart
ProjectName/app/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
value.each do |current_value|
unless current_value =~ /\A([\w+\-]\.?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\Z/i
record.errors[attribute] << (options[:message] || 'wrong email address')
end
end
end
end
Inheritance Over Configuration
We have a contract in Create operation
class Project < ApplicationRecord
class Create < Trailblazer::Operation
include Model
model Project, :create
contract do
property :user, virtual: true
property :title
property :measurement_unit
collection :real_costs, default: []
end
end
end
Inheritance Over Configuration
Subclassed operations will inherit behavior, the contract and representer.
class Project < ApplicationRecord
class Update < Create
model Project, :update
contract do
property :measurement_unit, writeable: false
end
end
After inheriting you’re free to change, override or rewrite contracts, business logic and representers.
Processing Data
In case of a successful validation the logic in the block gets executed.
def process(params)
@model = Comment.new
validate(params[:comment]) do |f|
f.save
send_email
end
end
- The #validate method yields the contract instance to the block.
- Push changed values from the form to the Comment model
- Afterwards it calls #save on the model itself
Processing Data
If we have only CRUD operation there is no need to assign model in process action
class Project < ApplicationRecord
class Create < Trailblazer::Operation
include Model
model Project, :find
end
end
By including Model and setting the :find action, the operation will take care of retrieving the correct model.
:create calls Project.new
:update or :find will execute Project.find(params[:id])
Processing Data
If we have only NON-CRUD operation we can assign model before the process action
class ProjectUser < ApplicationRecord
class AddUserToProject < Trailblazer::Operation
contract do
property :user, virtual: true
property :project, virtual: true
end
def model!(params)
params[:project]
end
def process(params)
.....
end
end
end
Processing Data
Marking Operation as Invalid
class ProjectUser < ApplicationRecord
class AddUserToProject < Trailblazer::Operation
contract do
property :user, virtual: true
property :project, virtual: true
end
def model!(params)
params[:project]
end
def process(params)
invalid! if model.users.include?(params[:user])
end
end
end
Wrapping Operations
class ProjectUser < ApplicationRecord
class AddUserToProject < Trailblazer::Operation
contract do
property :user, virtual: true
property :project, virtual: true
end
def model!(params)
params[:project]
end
def process(params)
invalid! if model.users.include?(params[:user])
end
end
end
Wrapping Operations
class ProjectsUser < ApplicationRecord
class AddUserToManyProject < Trailblazer::Operation
contract do
property :user_email, virtual: true
property :projects_ids, virtual: true
end
def process(params)
user = User.find_by_email(params[:user_email])
if user.present?
params[:projects_ids].each do |project_id|
project = Project.find(project_id)
res, op = ProjectsUser::AddUserToProject.run(project: project, user: user)
notify! if op.valid?
end
else
errors.add(:base,'Email was not found in our database.')
invalid!
end
end
end
end
Keep in touch
http://slides.com/liubovderevianko/trailblazer_operations
https://www.linkedin.com/in/liubov-derevianko
http://meetup.mobidev.com.ua/