-
Notifications
You must be signed in to change notification settings - Fork 18
HyperOperation Readme
@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
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.
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
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
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
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
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' }
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.
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.
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.
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.
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 {}
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
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.