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

HyperOperation Readme

Mitch VanDuyn edited this page Jan 31, 2017 · 4 revisions

@catmando

This is the proposed HyperOperation readme. Please let me know ASAP if you see any issues with the function, and feel free to update to make doc clearer as needed. Implementation is commencing forthwith!

HyperOperation

  • HyperOperation is the base class for Operations.
  • An Operation orchestrates the updating of the state of your system.
  • Operations also wrap asynchronous operations such as HTTP API requests.
  • Operations serve the role of both Action Creators and Dispatchers described in the Flux architecture.
  • Operations also server as the bridge between client and server. An operation can run on the client or the server, and can be invoked remotely.

Here is the simplest Operation:

class Reset < HyperOperation
end

To 'Reset' the system you would say

  Reset() # short for Reset.run

Elsewhere your HyperStores can receive the Reset Dispatch using the receives macro:

class Cart < HyperStore::Base
  receives Reset do
    mutate.items = Hash.new { |h, k| h[k] = 0 }
  end
end

Note that multiple stores can receive the same Dispatch.

Parameters

Operations can take parameters when they are run. Parameters are described and accessed with the same syntax as HyperReact components.

class AddItemToCart < HyperOperation
  param :sku, type: String
  param qty: 1, type: Numeric, minimum: 1
end

class Cart < HyperStore::Base
  receives AddItemToCart do
    mutate.items[params.sku] += params.qty
  end
end

The execute method

Every HyperOperation has an execute method. The base execute method dispatches (or broadcasts) the Operation parameters to all the Stores receiving the Operation's dispatches.

You can override execute to provide your own behavior and still call dispatch if you want to proceed with the dispatch.

class Reset < HyperOperation
  def execute
    dispatch
    HTTP.post('/logout')
  end
end

Asynchronous Operations

Operations are the place to put your asynchronous code:

class AddItemToCart < HyperOperation
  def execute
    HTTP.get('/inventory/#{params.sku}/qty').then do |response|
      # don't dispatch until we know we have qty in stock
      dispatch unless params.qty > response.to_i
    end
  end
end

This makes it easy to keep asynchronous code out of your stores.

HyperOperations will always return a Promise. If an Operation's execute method returns something other than a promise it will be wrapped in a resolved promise. This lets you easily chain Operations, regardless of their internal implementation:

class QuickCheckout < HyperOperation
  param :sku, type: String
  param qty: 1, type: Numeric, minimum: 1
  def execute
    AddItemToCart(params) do
      ValidateUserDefaultCC()
    end.then do
      Checkout()
    end
  end
end

You can also use Promise#when if you don't care about the order of Operations

class DoABunchOStuff < HyperOperation
  def execute 
    when(SomeOperation.run, SomeOtherOperation.run).then do 
      dispatch
    end
  end
end

Handling Failures

Because Operations always return a promise, you can use the fail method on the result to detect failures.

QuickCheckout(sku: selected_item, qty: selected_qty)
.then do
  # show confirmation
end
.fail do |reason|
  # show failure message
end

Dispatch Syntax

You can dispatch to an Operation by using ...

  • the Operation class name as a method:
    MyOperation()
    
  • the run method:
    MyOperation.run
    
  • the then method, which will dispatch the operation and attach a promise handler:
    MyOperation.then { alert 'operation completed' }
    

Uplinking Operations

HyperOperations can run on the client or the server. For example, an Operation like ValidateUserDefaultCC probably needs to check information server side, and perhaps make secure API calls to our credit card processor which again can only be done from the server. Rather than build an API and controller to "validate the user credentials" you simply uplink the operation to the server.

class ValidateUserCredentials < HyperOperation
  # uplink can only happen if there is a current acting user
  regulate_uplink { acting_user }
  def execute
    raise Cart::CheckoutFailure("no default credit card") unless acting_user.has_default_cc?
  end
end

The regulate_uplink regulation takes a block, a symbol (indicating a method to call) or a proc. If the block, proc or method returns a truthy value the client will be allowed to remotely dispatch the Operation on the server. If the block, proc or method returns a falsy value or raises an exception, the uplink will fail with a 403 error. If no block or parameter is provided the uplink is always allowed.

The uplink regulation will generally interrogate acting_user and the params object to determine if the current acting user has permission to execute the Operation. More on acting_user in the Authorization Policies guide.

Note that any Operation that has an uplink regulation will always run on the server.

Downlinking Operations

Likewise you can downlink Operations to the client:

class Announcement < HyperOperation
  param :message
  param :duration
  # downlink to the Application channel
  regulate_downlink Application
end

class CurrentAnnouncements < HyperStore::Base
  state_reader all: [], scope: :class
  receives Announcement do
    mutate.all << params.message
    after(params.duration) { delete params.message } if params.duration
  end
  def self.delete(message)
    mutate.all.delete message
  end
end

The regulate_downlink regulation takes a list of classes, representing Channels. The Operation will be dispatched to all clients connected on those Channels. Alternatively regulate_downlink can take a block, a symbol (indicating a method to call) or a proc. The block, proc or method should return a single Channel, or an array of Channels, which the Operation will be dispatched to. The downlink regulation has access to the params object. For example we can add an optional to param to our Operation, and use this to select which Channel we will broadcast to.

class Announcement < HyperOperation
  param :message
  param :duration
  param to: nil, type: User
  # downlink only to the Users channel if specified
  regulate_downlink do
    params.to || Application
  end
end

Find out more about Channels in the Authorization Policies guide.

Note that any Operation that has a downlink regulation will always run on the client.

Note that any Operation that has a downlink regulation will return true (wrapped in a promise) if dispatched from the server, and false otherwise.

Serialize and Deserialize

By default incoming parameters and outgoing results will be serialized and deserialized using the objects to_json method, and JSON.parse. You can override this by defining serializer and deserializer methods:

class Announcement < HyperOperation
  param :message
  param :duration
  param to: nil, type: user

  def serializer(serializing, value)
    return super unless serializing.user?
    value.full_name
  end
end

The value of the first parameter (serializing above) is a symbol with additional methods corresponding to each of the parameter names (i.e. message?, duration? and to?) plus exception? and result?

Make sure to call super unless you are serializing/deserializing all values.

Isomorphic Operations

If an Operation has no uplink or downlink regulations it will run on the same place as it was dispatched from. This can be handy if you have an Operation that needs to run on both the server and the client. For example an Operation that calculates the customers discount, will want to run on the client so the user gets immediate feedback, and then will be run again on the server when the order is submitted as a double check.

Dispatching With New Parameters

Sometimes it's useful for the execute method to process the incoming parameters before dispatching.

class AddItemToCart < HyperOperation
  param :sku, type: String
  param qty: 1, type: Numeric, minimum: 1

  dispatches :sku, :qty, :avail

  def execute
    HTTP.get('/inventory/#{params.sku}/qty').then do |response|
      dispatch params, avail: response.to_i unless params.qty > response.to_i
    end
  end
end

The dispatches method declares the expected set of parameters that will be sent on to the Stores.

If there is no dispatches then it is assumed that the same parameters will be sent onwards

The dispatch method by default will send the params onwards. You can also (as in the above) merge in other params, or completely overwrite the params as follows:

  dispatch {other_stuff: 15}, param_a: 12, param_b: 13
  # merges all the arguments together

If you don't plan to dispatch any parameters use an empty array:

  dispatches nil
  ...
    dispatch # or dispatch [] or dispatch {}

Instance Verses Class Execution Context

Normally the execute method is declared, and runs as an instance method. An instance of the Operation is created, runs and is thrown away.

Sometimes it's useful to declare execute as a class method. In this case all dispatches of the Operation will be run with the same class instance variables. This is useful especially for caching values, between calls to the Operation. Note that the primary use should be in interfacing to outside APIs. Don't hide your application state inside an Operation - Move it to a Store.

class GetRandomGithubUser < HyperOperation
  def self.execute
    return @users.delete_at(rand(@users.length)) unless @users.blank?
    @promise = HTTP.get("https://api.github.com/users?since=#{rand(500)}").then do |response|
      @users = response.json.collect do |user|
        { name: user[:login], website: user[:html_url], avatar: user[:avatar_url] }
      end
    end if @promise.nil? || @promise.resolved?
    @promise.then { execute }
  end
end

Flux and Operations

Hyperloop is a merger of the concepts of the Flux pattern, the Mutation Gem, and Trailblazer Operations.

We chose the name Operation rather than Action or Mutation because we feel it best captures all the capabilities of a HyperOperation. Nevertheless HyperOperations are fully compatible with the Flux Pattern.

Flux HyperLoop
Action HyperOperation subclass
ActionCreator HyperOperation#execute method
Action Data HyperOperation parameters
Dispatcher HyperOperation#dispatch method
Registering a Store Store.receives

In addition Operations have the following capabilities:

  • Can easily be chained because they always return promises.
  • Clearly declare both their parameters, and what they will dispatch.
  • Parameters can be validated and type checked.
  • Can run remotely on the server.
  • Can be dispatched from the server to all authorized clients.
  • Can hold their own state data when appropriate.
Clone this wiki locally