Skip to content
This repository has been archived by the owner on Oct 19, 2018. It is now read-only.

Improving the Operation Syntax

Mitch VanDuyn edited this page Feb 19, 2017 · 5 revisions

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 }
end

why? 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 }
end

It 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:

  • param declares params
  • outbound declares outbound dispatched data
  • failure callbacks that are run if anything before the failure point fails
  • validate callbacks that are run after params are processed. returning false or raising an exception fails the validation.
  • step callbacks that are run after validations are complete
  • dispatch call backs are run as after the last step, and sends the params and outbound data to stores.
  • on_dispatch is how stores attach to the dispatches.

Notes:

Validations and Failures

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 } 
end

Likewise 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
end

which 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
  end

we 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 { return! @users.delete_at(rand(@users.length)) unless @users.blank? }
    step { return! @promise.then { run } if @promise && !@promise.realized? }
    step { @promise = get_more_users }
    step { |users| @users = users }
    step { run }
  end
end

going to failure track?

  • exceptions move to failure track
  • failed promises move to failure track
  • fail (this simply which raises an exception)

skipping to end: return! and abort!

use self.class.run to rerun the operation

Clone this wiki locally