- 
                Notifications
    You must be signed in to change notification settings 
- Fork 18
Improving the Operation Syntax
This syntax seems ugly:
class Announcement < Hyperloop:ServerOp 
  param :acting_user
  param :receiver
  param :message
  def validate 
    raise Hyperloop::AccessViolation unless params.acting_user.admin?
    params.receiver = User.find_by_login(params.receiver)
  end
  dispatch_to { params.receiver }
endwhy? It's inconsistent... we have a mix of macro declarations (param and dispatch_to) and methods (validate, execute). This is not only internally inconsistent, but also inconsistent with the Components class.
It also makes validate problematic.  If I going to create a base class Operation (like AdminOperation) which has a validate method, then I really want my subclasses to also run my validate method (contrary to normal ruby inheritance rules)
class AdminOp < Hyperloop::ServerOp
  param :acting_user
  def validate 
    # I hope my subclasses remember to call super, maybe ServerOp baseclass
    # will make sure this happens?
    raise Hyperloop::AccessViolation unless params.acting_user.admin?
  end
end
class Announcement < AdminOp 
  param :receiver
  param :message
  def validate 
    # i hope Hyperloop::ServerOp calls super for me????
    params.receiver = User.find_by_login(params.receiver)
  end
  dispatch_to { params.receiver }
endIt is quite possible to make sure that all superclass validate methods will get called, but it seems in bad taste. As a developer I don't want to have to "remember" that this validate method works specially.
To solve these problems we should use more ideas stolen from Trailblazer 2.0 / railway programming / monad
We make the structure of Operations a series of macros and callbacks including:
- 
paramdeclares params
- 
outbounddeclares outbound dispatched data
- 
failurecallbacks that are run if anything before the failure point fails
- 
validatecallbacks that are run after params are processed. returning false or raising an exception fails the validation.
- 
stepcallbacks that are run after validations are complete
- 
dispatchcall backs are run as after the last step, and sends the params and outbound data to stores.
- 
on_dispatchis how stores attach to the dispatches.
Notes:
incoming params are processed, and any validation failures are recorded. To immediately exit the Operation at this point (without running further validations) you can add
failure { abort! }
but normally you want all the validations to be run and recorded.
class AdminOp < Hyperloop::ServerOp
  param :acting_user
  validate { raise Hyperloop::AccessViolation unless params.acting_user.admin? }
end
class Announcement < Hyperloop::ServerOp 
  param :receiver
  param :message
  validate { params.receiver = User.find_by_login(params.receiver) }
  dispatch { params.receiver } 
endLikewise we use the trailblazer step in place of execute,
class DeleteUser < AdminOp 
  param :user
  validate { params.user = User.find_by_login(params.user) } # user exists
  validate { params.user != params.acting_user } # can't delete myself
  validate { !params.user.admin? || AdminUser.count == 1 } # can't delete last admin user
  step { SendDeleteMailNotice(params) }
  step { param.user.delete }
  dispatch { params.user } # if user is logged in, we can log em off
endwhich cleans up promises nicely on the client side:
Instead of
  def execute
    CreateCloudTempUrl(file_name: params.file_name).then do |url, uuid|
      HTTP.put(url, params_for_put).then do
        CopyFromTempUrl(file_name: uuid)
      end
    end
  endwe can say
  step { CreateCloudTempUrl(file_name: params.file_name) }
  step { |url, uuid| HTTP.put(url, params_for_put).then { uuid } }
  step { |uuid| CopyFromTempUrl(file_name: uuid) }
and now def self.execute becomes step scope: :class which is beautifully consistent with store syntax:
# old school:
class GetRandomUserOperation < Hyperloop::Operation
  def self.execute
    return @users.delete_at(rand(@users.length)) unless @users.blank?
    @promise = get_more_users.then do |users|
      @users = users
    end if @promise.nil? || @promise.resolved?
    @promise.then { execute }
  end
end# now
class GetRandomUserOperation < Hyperloop::Operation
  step scope: :class do 
    next @users.delete_at(rand(@users.length)) unless @users.blank?
    @promise = get_more_users.then do |users|
      @users = users
    end if @promise.nil? || @promise.resolved?
    @promise.then { run }
  end
end# or even
class GetRandomUserOperation < Hyperloop::Operation
  class << self
    step { succeed! @users.delete_at(rand(@users.length)) unless @users.blank? }
    step { succeed! @promise.then { run } if @promise && !@promise.realized? }
    step { @promise = get_more_users.then { |users| @users = users } }
    step { run }
  end
end# or even
class GetRandomUserOperation < Hyperloop::Operation
  class << self
    step scope: :class { succeed! @users.delete_at(rand(@users.length)) unless @users.blank? }
    step scope: :class { succeed! @promise.then { run } if @promise && !@promise.realized? }
    step scope: :class { @promise = get_more_users }
    step scope: :class { |op, users| @users = users } }
    step { run }
  end
endgoing to failure track?
- exceptions move to failure track
- failed promises move to failure track
- fail (this simply which raises an exception)
skipping to end: succeed! and abort!
use self.class.run to rerun the operation