Operations
HyperOperations are HyperStack's implementation of Service Objects based on Trailblazer Operations. Operations can be used on the client, the server, or can act like a remote procedure call mechanism communicating between the client and the server.

This Page Under Construction

Why do we need Service Objects? Because in any real world system you have logic that does not belong in models (or stores) because it effects multiple models or stores, and it does not belong in components because the logic of the task is independent of the specific user interface design. In MVC frameworks this kind of logic is often shoved in the controller, but it doesn't belong there either.
There are also those boundary areas between gathering and processing external data and getting data into or out of our stores and models. You don't want that kind of logic in your model or store, so where does it go? It belongs in a service object or Operation in Hyperstack terminology.
The term Operation, the key concepts of the Operation, and a lot of the implementation was taken from Trailblazer
Simply put an Operation is like a large standalone method that has no internal state of its own. You run an operation, it does it thing, and it returns an answer.
Any state that an operation needs to retrieve or save is stored somewhere else: in a model, a store, or even in a remote API. Once the operation completes, it has no memory of its own.
Being a stand-alone, glue and business logic method is an Operation's full time mission. The Hyperstack Operation base class is therefor structured to make writing this kind of code easy.
  • An Operation may take parameters (params) just like any other method;
  • An Operation may validate the parameters;
  • An Operation then executes a number of steps;
  • The steps can be part of a success track or a failure track;
  • The value of the final step is returned to the caller;
  • And the results can be broadcast to any interested parties.
Hyperstack's Operations often involve asynchronous methods such as HTTP requests and so Operations always return Promises. Likewise each of the steps of an Operation can itself be an asynchronous action, and the Operation class will take care of chaining the promises together for you.
Another key feature of Operations is that because they are stateless they make a perfect RPC (Remote Procedure Call) mechanism. So an Operation can be called on the client, but will run on the server, and then return or broadcast the results to the clients. Thus Operations form the underlying data transport mechanism between the server and clients.
That is a lot to digest, and truly Operations are the swiss-army knife of Hyperstack. So let's dive into some examples.
In this simple example we are going to use a third-party API to determine our browser's IP address. First without Operations:
1
class App < HyperComponent
2
before_mount do
3
HTTP.get('https://api.ipify.org?format=json').then do |response|
4
mutate @ip_address = response.json[:ip]
5
end
6
end
7
8
render do
9
H1 { "Hello world from Hyperstack your ip address: #{@ip_address}" }
10
end
11
end
Copied!
Nice and simple. Our App mounts, does a HTTP get from our API, and when it returns it updates the state. The problem is our view logic is cluttered up with low level specifics of how to get the address. Lets fix that by moving that logic to a separate service object:
1
class GetIPAddress
2
def self.run
3
HTTP.get('https://api.ipify.org?format=json').then do |response|
4
response.json[:ip]
5
end
6
end
7
end
Copied!
Notice that the object is stateless and because it has no state it is simply a class method. We then use our service object like this:
1
class App < HyperComponent
2
before_mount do
3
GetIPAddress.run.then { |ip_address| mutate @ip_address = ip_address}
4
end
5
6
render do
7
H1 { "Hello world from Hyperstack. Your ip address is #{@ip_address}" }
8
end
9
end
Copied!
If we were to change how we get the IP address, the Component now doesn't have to change.
Now we will redefine our service object using the Hyperstack::Operation class.
1
class GetIPAddress < Hyperstack::Operation
2
step { HTTP.get('https://api.ipify.org?format=json') }
3
step { |response| response.json[:ip] }
4
end
Copied!
You invoke Operations using the run method, so our Component does not have to change at all.
The advantage is that the Operation syntax takes care of a lot of clutter, allows our promise to be chained neatly, and makes our intention clear to the reader.
We will see how these advantages multiply as our example becomes more complex.
Before moving on lets understand the basics of Operations.
  • Every Operation has as its external API a single run method.
  • The work of the Operation is defined by a series of steps.
  • When the run method is called, the code associated with each step is executed.
  • If a step returns a promise the next step will wait till the promise is resolved.
  • The result of the final step is wrapped in a promise and is the result of the operation.
The final point means that regardless of the Operation's internal implementation, the Operation always returns a promise, so its API is consistent. As operations always return promises you can simply apply the then and fail promise methods directly to the Operation rather than saying Op.run.then.
Let's say that rather than a simple ip address what we want is a full set of geo-location data. We can use another third party API to do the job. This API requires we supply our IP address, so we will reuse our IPAddress Op.
1
class GetGeoData < Hyperstack::Operation
2
step GetIPAddress
3
step { |ip_address| HTTP.get("https://ipapi.co/#{ip_address}/json/") }
4
step { |response| response.json }
5
end
Copied!
Here we can see one of the different ways to define a step: We simply delegate the first step to our already defined GetIPAddress operation.
Again lets compare to a traditional ServiceObject:
1
class GetGeoData
2
def self.run
3
IPAddress.run.then do |ip_address|
4
HTTP.get("https://ipapi.co/#{ip_address}/json/")
5
end.then do |response|
6
response.json
7
end
8
end
9
end
Copied!
Again its the same logic, but the body of our service object is over twice the number of lines and logic is obscured by the promise handlers.
It would be nice if we could include a flag icon to go with the country in the response data. Lets do that:
1
class GetGeoData < Hyperstack::Operation
2
step IPAddress
3
step { |ip_address| HTTP.get("https://ipapi.co/#{ip_address}/json/") }
4
step { |response| response.json }
5
step { |json| json.merge flag_url: "https://www.countryflags.io/#{json['country']}/shiny/64.png" }
6
end
Copied!
Of course its just Ruby, so we can further clean up our code by defining some helper methods:
1
class GetGeoData < Hyperstack::Operation
2
step IPAddress
3
step { |ip_address| HTTP.get(geo_data_url_for(ip_address)) }
4
step { |response| response.json }
5
step { |json| json.merge flag_url: flag_url_for(json['country']) }
6
7
def geo_data_url_for(ip_address)
8
"https://ipapi.co/#{ip_address}/json/"
9
end
10
11
def flag_url_for(country_code)
12
"https://www.countryflags.io/#{country_code}/shiny/64.png"
13
end
14
end
Copied!
Our GetGeoData uses two remote third party operations, which may occasionally fail so we add a retry mechanism. This will introduce four new features of Operation: The failure track, parameters, and the abort! and succeed! methods.
Tracks
Operations have two tracks of execution. The normal success track which is defined by the step method, and a failure track which is defined by a series of failed methods.
Execution begins with the first step, and continues with each step until an exception is raised, or a promise fails. When that happens execution jumps to the next failed step, and the continues executing failed steps. The result of the Operation will be value of the last failed step, and the Operation's promise will be be rejected (i.e. will be in the fail state.)
Parameters
Operations can take a series of named parameters defined by the param method. Parameters can have type information, defaults, and can be validated. This helps Operations act like a firewall between various parts of the system, making debugging and error handling easier. For now we are just going to use a simple case of a parameter that takes a default value.
The abort! and succeed! methods
These provide an early exit like return, break and next statements. Calling abort! and succeed! immediately exits the Operation by the appropriate track.
Putting it together:
1
class GetGeoData < Hyperstack::Operation
2
param attempts: 0
3
4
step IPAddress
5
step { |ip_address| HTTP.get(geo_data_url_for(ip_address)) }
6
step { |response| response.json }
7
step { |json| json.merge flag_url: flag_url_for(json['country']) }
8
9
failed { abort! if params.attempts > 3 }
10
failed { sleep 1.second }
11
failed { GeoData.run(attempts: params.attempts+1).then(&:succeed!) }
12
13
def geo_data_url_for(ip_address)
14
"https://ipapi.co/#{ip_address}/json/"
15
end
16
17
def flag_url_for(country_code)
18
"https://www.countryflags.io/#{country_code}/shiny/64.png"
19
end
20
end
Copied!
; they orchestrate the interactions between Components, external services, Models, and Stores. Operations provide a tidy place to keep your business logic.
Operations receive parameters, va and execute a series of steps They have a simple structure which is not dissimilar to a Component:
1
class SimpleOperation < Hyperstack::Operation
2
param :anything
3
step { do_something }
4
end
5
6
#to invoke from anywhere
7
SimpleOperation.run(anything: :something)
8
.then { success }
9
.fail { fail }
Copied!
Hyperstack's Isomorphic Operations span the client and server divide automagically. Operations can run on the client, the server, and traverse between the two.
This goal of this documentation is to outline Operations classes and provides enough information and examples to show how to implement Operations in an application.

Operations have three core functions

Operations are packaged as one neat package but perform three different functions:
  1. 1.
    Operations encapsulate business logic into a series of steps
  2. 2.
    Operations can dispatch messages (either on the client or between the client and server)
  3. 3.
    ServerOps can be used to replace boiler-plate APIs through a bi-directional RPC mechanism
Important to understand: There is no requirement to use all three functions. Use only the functionality your application requires.

Operations encapsulate business logic

In a traditional MVC architecture, the business logic ends up either in Controllers, Models, Views or some other secondary construct such as service objects, helpers, or concerns. In Hyperstack, Operations are first class objects who's job is to mutate state in the Stores, Models, and Components. Operations are discreet logic, which is of course, testable and maintainable.
An Operation does the following things:
  1. 1.
    receives incoming parameters, and does basic validations
  2. 2.
    performs any further validations
  3. 3.
    executes the operation
  4. 4.
    dispatches to any listeners
  5. 5.
    returns the value of the execution (step 3)
These are defined by series of class methods described below.

Operation Structure

Hyperstack::Operation is the base class for an Operation
As an example, here is an Operation which ensures that the Model being saved always has the current created_by and updated_by Member.
1
class SaveWithUpdatingMemberOp < Hyperstack::Operation
2
param :model
3
step { params.model.created_by = Member.current if params.model.new? }
4
step { params.model.updated_by = Member.current }
5
step { model.save.then { } }
6
end
Copied!
This Operation is run from anywhere in the client or server code:
1
SaveWithUpdatingMemberOp.run(model: MyModel)
Copied!
Operations always return Promises, and those Promises can be chained together. See the section on Promises later in this documentation for details on how Promises work.
Operations can invoke other Operations so you can chain a sequence of steps and Promises which proceed unless the previous step fails:
1
class InvoiceOpertion < Hyperstack::Operation
2
param :order, type: Order
3
param :customer, type: Customer
4
5
step { CheckInventoryOp.run(order: params.order) }
6
step { BillCustomerOp.run(order: params.order, customer: params.customer) }
7
step { DispatchOrderOp.run(order: params.order, customer: params.customer) }
8
end
Copied!
This approach allows you to build readable and testable workflows in your application.

Running Operations

To run an Operation:
  • use the run method:
1
MyOperation.run
Copied!
  • passing params:
1
MyOperation.run(params)
Copied!
  • the then and fail methods, which will dispatch the operation and attach a promise handler:
1
MyOperation.run(params)
2
.then { do_the_next_thing }
3
.fail { puts 'failed' }
Copied!

Parameters

Operations can take parameters when they are run. Parameters are described and accessed with the same syntax as Hyperstack Components.
The parameter filter types and options are taken from the Mutations gem with the following changes:
  • In Hyperstack::Operations all params are declared with the param macro
  • The type can be specified using the type: option
  • Array and hash types can be shortened to [] and {}
  • Optional params either have the default value associated with the param name or by having the default option present
  • All other Mutation filter options (such as :min) will work the same
1
# required param (does not have a default value)
2
param :sku, type: String
3
# equivalent Mutation syntax
4
# required { string :sku }
5
6
# optional params (does have a default value)
7
param qty: 1, min: 1
8
# alternative syntax
9
param :qty, default: 1, min: 1
10
# equivalent Mutation syntax
11
# optional { integer :qty, default: 1, min: 1 }
Copied!
All incoming params are validated against the param declarations, and any errors are posted to the @errors instance variable. Extra params are ignored, but missing params unless they have a default value will cause a validation error.

Defining Execution Steps

Operations may define a sequence of steps to be executed when the operation is run, using the step, failed and async callback macros.
1
class Reset < Hyperstack::Operation
2
step { HTTP.post('/logout') }
3
end
Copied!
  • step: runs a callback - each step is run in order.
  • failed: runs a callback if a previous step or validation has failed.
  • async: will be explained below.
1
step { } # do something
2
step { } # do something else once above step is done
3
failed { } # do this if anything above has failed
4
step { } # do a third thing, unless we are on the failed track
5
failed { } # do this if anything above has failed
Copied!
Together step and failed form two railway tracks. Initially, execution proceeds down the success track until something goes wrong; then execution switches to the failure track starting at the next failed statement. Once on the failed track execution continues performing each failed callback and skipping any step callbacks.
Failure occurs when either an exception is raised, or a Promise fails (more on this in the next section.) The Ruby fail keyword can be used as a simple way to switch to the failed track.
Both step and failed can receive any results delivered by the previous step. If the last step raised an exception (outside a Promise), the failure track would receive the exception object.
The callback may be provided to step and failed either as a block, a symbol (which will name a method), a proc, a lambda, or an Operation.
1
step { puts 'hello' }
2
step :say_hello
3
step -> () { puts 'hello' }
4
step Proc.new { puts 'hello' }
5
step SayHello # your params will be passed along to SayHello
Copied!
FYI: You can also use the Ruby next keyword as expected to leave the current step and move to the next one.

Promises and Operations

Within the browser, the code does not wait for asynchronous methods (such as HTTP requests or timers) to complete. Operations use Opal's Promise library to deal with these situations cleanly. A Promise is an object that has three states: It is either still pending, or has been rejected (i.e. failed), or has been successfully resolved. A Promise can have callbacks attached to either the failed or resolved state, and these callbacks will be executed once the Promise is resolved or rejected.
If a step or failed callback returns a pending Promise then the execution of the operation is suspended, and the Operation will return the Promise to the caller. If there is more track ahead, then execution will resume at the next step when the Promise is resolved. Likewise, if the pending Promise is rejected execution will resume on the next failed callback. Because of the way Promises work, the operation steps will all be completed before the resolved state is passed along to the caller so that everything will execute in its original order.
Likewise, the Operation's dispatch occurs when the Promise resolves as well.
The async method can be used to override the waiting behavior. If a step returns a Promise, and there is an async callback further down the track, execution will immediately pick up at the async. Any steps in between will still be run when the Promise resolves, but their results will not be passed outside of the operation.
These features make it easy to organize, understand and compose asynchronous code:
1
class AddItemToCart < Hyperstack::Operation
2
step { HTTP.get('/inventory/#{params.sku}/qty') }
3
# previous step returned a Promise so next step
4
# will execute when that Promise resolves
5
step { |response| fail if params.qty > response.to_i }
6
# once we are sure we have inventory we will dispatch
7
# to any listening stores.
8
end
Copied!
Operations will always return a Promise. If an Operation has no steps that return a Promise the value of the last step will be wrapped in a resolved Promise. Operations can be easily changed regardless of their internal implementation:
1
class QuickCheckout < Hyperstack::Operation
2
param :sku, type: String
3
param qty: 1, type: Integer, minimum: 1
4
5
step { AddItemToCart.run(params) }
6
step ValidateUserDefaultCC
7
step Checkout
8
end
Copied!
You can also use Promise#when if you don't care about the order of Operations
1
class DoABunchOStuff < Hyperstack::Operation
2
step { Promise.when(SomeOperation.run, SomeOtherOperation.run) }
3
# dispatch when both operations complete
4
end
Copied!

Early Exits

Any step or failed callback, can have an immediate exit from the Operation using the abort! and succeed! methods. The abort! method returns a failed Promise with any supplied parameters. The succeed! method does an immediate dispatch and returns a resolved Promise with any supplied parameters. If succeed! is used in a failed callback, it will override the failed status of the Operation. This is especially useful if you want to dispatch in spite of failures:
1
class Pointless < Hyperstack::Operation
2
step { fail } # go to failure track
3
failed { succeed! } # dispatch and exit
4
end
Copied!

Validation

An Operation can also have some validate callbacks which will run before the first step. This is a handy place to put any additional validations. In the validate method you can add validation type messages using the add_error method, and these will be passed along like any other param validation failures.
1
class UpdateProfile < Hyperstack::Operation
2
param :first_name, type: String
3
param :last_name, type: String
4
param :password, type: String, nils: true
5
param :password_confirmation, type: String, nils: true
6
7
validate do
8
add_error(
9
:password_confirmation,
10
:doesnt_match,
11
"Your new password and confirmation do not match"
12
) unless params.password == params.confirmation
13
end
14
15
# or more simply:
16
17
add_error :password_confirmation, :doesnt_match, "Your new password and confirmation do not match" do
18
params.password != params.confirmation
19
end
20
21
...
22
end
Copied!
If the validate method returns a Promise, then execution will wait until the Promise resolves. If the Promise fails, then the current validation fails.
abort! can be called from within validate or add_error to exit the Operation immediately. Otherwise, all validations will be run and collected together, and the Operation will move onto the failed track. If abort! is called within an add_error callback the error will be added before aborting.
You can also raise an exception directly in validate if appropriate. If a Hyperstack::AccessViolation exception is raised the Operation will immediately abort, otherwise just the current validation fails.
To avoid further validations if there are any failures in the basic parameter validations, this can be added
1
validate { abort! if has_errors? }
Copied!
before the first validate or add_error call.

Handling Failed Operations

Because Operations always return a promise, the Promise's fail method can be used on the Operation's result to detect failures.
1
QuickCheckout.run(sku: selected_item, qty: selected_qty)
2
.then do
3
# show confirmation
4
end
5
.fail do |exception|
6
# whatever exception was raised is passed to the fail block
7
end
Copied!
Failures to validate params result in Hyperstack::ValidationException which contains a Mutations error object.
1
MyOperation.run.fail do |e|
2
if e.is_a? Hyperstack::ValidationException
3
e.errors.symbolic # hash: each key is a parameter that failed validation,
4
# value is a symbol representing the reason
5
e.errors.message # same as symbolic but message is in English
6
e.errors.message_list # array of messages where failed parameter is
7
# combined with the message
8
end
9
end
Copied!

Instance Versus Class Execution Context

Typically the Operation's steps are declared and run in the context of an instance of the Operation. An instance of the Operation is created, runs and is thrown away.
Sometimes it's useful to run a step (or other macro such as validate) in the context of the class. This is useful especially for caching values between calls to the Operation. This can be done by defining the steps in the class context, or by providing the option scope: :class to the step.
Note that the primary use should be in interfacing to an outside APIs. Application state should not be hidden inside an Operation, and it should be moved to a Store.
1
class GetRandomGithubUser < Hyperstack::Operation
2
def self.reload_users
3
@promise = HTTP.get("https://api.github.com/users?since=#{rand(500)}").then do |response|
4
@users = response.json.collect do |user|
5
{ name: user[:login], website: user[:html_url], avatar: user[:avatar_url] }
6
end
7
end
8
end
9
self.class.step do # as one big step
10
return @users.delete_at(rand(@users.length)) unless @users.blank?
11
reload_users unless @promise && @promise.pending?
12
@promise.then { run }
13
end
14
end
15
# or
16
class GetRandomGithubUser < Hyperstack::Operation
17
class << self # as 4 steps - whatever you like
18
step { succeed! @users.delete_at(rand(@users.length)) unless @users.blank? }
19
step { succeed! @promise.then { run } if @promise && @promise.pending? }
20
step { self.class.reload_users }
21
async { @promise.then { run } }
22
end
23
end
Copied!
An instance of the operation is always created to hold the current parameter values, dispatcher, etc. The first parameter to a class level step block or method (if it takes parameters) will always be the instance.
1
class Interesting < Hyperstack::Operation
2
param :increment
3
param :multiply
4
outbound :result
5
outbound :total
6
step scope: :class { @total ||= 0 }
7
step scope: :class { |op| op.params.result = op.params.increment * op.params.multiply }
8
step scope: :class { |op| op.params.total = (@total += op.params.result) }
9
dispatch
10
end
Copied!

The Boot Operation

Hyperstack includes one predefined Operation, Hyperstack::Application::Boot, that runs at system initialization. Stores can receive Hyperstack::Application::Boot to initialize their state. To reset the state of the application, you can just execute Hyperstack::Application::Boot

Operations can dispatch messages

Hyperstack Operations borrow from the Flux pattern where Operations are dispatchers and Stores are receivers. The choice to use Operations in this depends entirely on the needs and design of your application.
To illustrate this point, here is the simplest Operation:
1
class Reset < Hyperstack::Operation
2
end
Copied!
To 'Reset' the system you would say
1
Reset.run
Copied!
Elsewhere your HyperStores can receive the Reset Dispatch using the receives macro:
1
class Cart < Hyperstack::Store
2
receives Reset do
3
mutate.items Hash.new { |h, k| h[k] = 0 }
4
end
5
end
Copied!
Note that multiple stores can receive the same Dispatch.
Note: Flux pattern vs. Hyperstack Operations Operations serve the role of both Action Creators and Dispatchers described in the Flux architecture. We chose the name Operation rather than Action or Mutation because we feel it best captures all the capabilities of a Hyperstack::Operation. Nevertheless, Operations are fully compatible with the Flux Pattern.

Dispatching With New Parameters

The dispatch method sends the params object on to any registered receivers. Sometimes it's useful to add additional outbound params before dispatching. Additional params can be declared using the outbound macro:
1
class AddItemToCart < Hyperstack::Operation
2
param :sku, type: String
3
param qty: 1, type: Integer, minimum: 1
4
outbound :available
5
6
step { HTTP.get('/inventory/#{params.sku}/qty') }
7
step { |response| params.available = response.to_i }
8
step { fail if params.qty > params.available }
9
dispatch
10
end
Copied!

Dispatching messages or invoking steps (or both)?

Facebook is very keen on their Flux architecture where messages are dispatched between receivers. In an extensive and complicated front end application it is easy to see why they are drawn to this architecture as it creates an independence and isolation between Components.
As stated earlier in this documentation, the step idea came from Trailblazer, which is an alternative Rails architecture that posits that business functionality should not be kept in the Models, Controllers or Views.
In designing Hyperstack's Isomorphic Operations (which would run on the client and the server), we decided to borrow from the best of both architectures and let Operations work in either way. The decision as to adopt the dispatching or stepping based model is left down to the programmer as determined by their preference or the needs of their application.

ServerOps can be used to replace boiler-plate APIs

Some Operations simply do not make sense to run on the client as the resources they depend on may not be available on the client. For example, consider an Operation that needs to send an email - there is no mailer on the client so the Operation has to execute from the server.
That said, with our highest goal being developer productivity, it should be as invisible as possible to the developer where the Operation will execute. A developer writing front-end code should be able to invoke a server-side resource (like a mailer) just as easily as they might invoke a client-side resource.
Hyperstack ServerOps replace the need for a boiler-plate HTTP API. All serialization and de-serialization of params are handled by Hyperstack. Hyperstack automagically creates the API endpoint needed to invoke a function from the client which executes on the server and returns the results (via a Promise) to the calling client-side code.

Server Operations

Operations will run on the client or the server. However, some Operations like ValidateUserDefaultCC probably need to check information server side and make secure API calls to our credit card processor. Rather than build an API and controller to "validate the user credentials" you just specify that the operation must run on the server by using the Hyperstack::ServerOp class.
1
class ValidateUserCredentials < Hyperstack::ServerOp
2
param :acting_user
3
add_error :acting_user, :no_valid_default_cc, "No valid default credit card" do
4
!params.acting_user.has_default_cc?
5
end
6
end
Copied!
A Server Operation will always run on the server even if invoked on the client. When invoked from the client, the ServerOp will receive the acting_user param with the current value that your ApplicationController's acting_user method returns. Typically the acting_user method will return either some User model or nil (if there is no logged in user.) It's up to you to define how acting_user is computed, but this is easily done with any of the popular authentication gems. Note that unless you explicitly add nils: true to the param declaration, nil will not be accepted.
Note regarding Rails Controllers: Hyperstack is quite flexible and rides along side Rails, without interfering. So you could still have your old controllers, and invoke them the "non-Hyperstack" way by doing say an HTTP.post from the client, etc. Hyperstack adds a new mechanism for communicating between client and server called the Server Operation (which is a subclass of Operation.) A ServerOp has no implication on your existing controllers or code, and if used replaces controllers and client side API calls. HyperModel is built on top of Rails ActiveRecord models, and Server Operations, to keep models in sync across the application. ActiveRecord models that are made public (by moving them to the Hyperstack/models folder) will automatically be synchronized across the clients and the server (subject to permissions given in the Policy classes.) Like Server Operations, HyperModel completely removes the need to build controllers, and client side API code. However all of your current active record models, controllers will continue to work unaffected.
As shown above, you can also define a validation to ensure further that the acting user (with perhaps other parameters) is allowed to perform the operation. In the above case that is the only purpose of the Operation. Another typical use would be to make sure the current acting user has the correct role to perform the operation:
1
...
2
validate { raise Hyperstack::AccessViolation unless params.acting_user.admin? }
3
...
Copied!
You can bake this kind logic into a superclass:
1
class AdminOnlyOp < Hyperstack::ServerOp
2
param :acting_user
3
validate { raise Hyperstack::AccessViolation unless params.acting_user.admin? }
4
end
5
6
class DeleteUser < AdminOnlyOp
7
param :user
8
add_error :user, :cant_delete_user, "Can't delete yourself, or the last admin user" do
9
params.user == params.acting_user || (params.user.admin? && AdminUsers.count == 1)
10
end
11
end
Copied!
Because Operations always return a Promise, there is nothing to change on the client to call a Server Operation. A Server Operation will return a Promise that will be resolved (or rejected) when the Operation completes (or fails) on the server.

Isomorphic Operations

Unless the Operation is a Server Operation, it will run where it was invoked. 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.

Parameters and ServerOps

You cannot pass an object from the client to the server as a parameter as the server has no way of knowing the state of the object. Hyperstack takes a traditional implementation approach where an id (or some unique identifier) is passed as the parameter and the receiving code finds and created an instance of that object. For example:
1
class IndexBookOp < Hyperstack::ServerOp
2
param :book_id
3
step { index_book Book.find_by_id params.book_id }
4
end
Copied!

Restricting server code to the server

There are valid cases where you will not want your ServerOp's code to be on the client yet still be able to invoke a ServerOp from client or server code. Good reasons for this would include:
  • Security concerns where you would not want some part of your code on the client
  • Size of code, where there will be unnecessary code downloaded to the client
  • Server code using backticks (`) or the %x{ ... } sequence, both of which are interpreted on the client as escape to generate JS code.
To accomplish this, you wrap the server side implementation of the ServerOp in a RUBY_ENGINE == 'opal' test which acts as a compiler directive so that this code is not compiled by Opal.
There are several strategies you can use to apply the RUBY_ENGINE == 'opal' guard to your code.
1
# strategy 1: guard blocks of code and declarations that you don't want to compile to the client
2
class MyServerOp < Hyperstack::ServerOp
3
# stuff that is okay to compile on the client
4
# ... etc
5
unless RUBY_ENGINE == 'opal'
6
# other code that should not be compiled to the client...
7
end
8
end
Copied!
1
# strategy 2: guard individual methods
2
class MyServerOp < Hyperstack::ServerOp
3
# stuff that is okay to compile on the client
4
# ... etc
5
def my_secret_method
6
# do something we don't want to be shown on the client
7
end unless RUBY_ENGINE == 'opal'
8
end
Copied!
1
# strategy 3: describe class in two pieces
2
class MyServerOp < Hyperstack::ServerOp; end # publically declare the operation
3
# provide the private implementation only on the server
4
class MyServerOp < Hyperstack::ServerOp
5
#
6
end unless RUBY_ENGINE == 'opal'
Copied!
Here is a fuller example:
1
# app/Hyperstack/operations/list_files.rb
2
class ListFiles < Hyperstack::ServerOp
3
param :acting_user, nils: true
4
param pattern: '*'
5
step { run_ls }
6
7
# because backticks are interpreted by the Opal compiler as escape to JS, we
8
# have to make sure this does not compile on the client
9
def run_ls
10
`ls -l #{params.pattern}`
11
end unless RUBY_ENGINE == 'opal'
12
end
13
14
# app/Hyperstack/components/app.rb
15
class App < Hyperstack::Component
16
state files: []
17
18
after_mount do
19
@pattern = ''
20
every(1) { ListFiles.run(pattern: @pattern).then { |files| mutate.files files.split("\n") } }
21
end
22
23
render(DIV) do
24
INPUT(defaultValue: '')
25
.on(:change) { |evt| @pattern = evt.target.value }
26
DIV(style: {fontFamily: 'Courier'}) do
27
state.files.each do |file|
28
DIV { file }
29
end
30
end
31
end
32
end
Copied!

Dispatching From Server Operations

You can also broadcast the dispatch from Server Operations to all authorized clients. The dispatch_to will determine a list of channels to broadcast the dispatch to:
1
class Announcement < Hyperstack::ServerOp
2
# no acting_user because we don't want clients to invoke the Operation
3
param :message
4
param :duration, type: Float, nils: true
5
# dispatch to the built-in Hyperstack::Application Channel
6
dispatch_to Hyperstack::Application
7
end
8
9
class CurrentAnnouncements < Hyperstack::Store
10
state_reader all: [], scope: :class
11
receives Announcement do
12
mutate.all << params.message
13
after(params.duration) { delete params.message } if params.duration
14
end
15
def self.delete(message)
16
mutate.all.delete message
17
end
18
end
Copied!

Channels

As seen above broadcasting is done over a Channel. Any Ruby class (including Operations) can be used as class channel. Any Ruby class that responds to the id method can be used as an instance channel.
For example, the User active record model could be a used as a channel to broadcast to all users. Each user instance could also be a separate instance channel that would be used to broadcast to a specific user.
The purpose of having channels is to restrict what gets broadcast to who, therefore typically channels represent connections to
  • the application (represented by the Hyperstack::Application class)
  • or some function within the application (like an Operation)
  • or some class which is authenticated like a User or Administrator,
  • instances of those classes,
  • or instances of classes in some relationship - like a team that a user belongs to.
A channel can be created by including the Hyperstack::Policy::Mixin, which gives three class methods: regulate_class_connection always_allow_connection and regulate_instance_connections.
For example...
1
class User < ActiveRecord::Base
2
include Hyperstack::Policy::Mixin
3
regulate_class_connection { self }
4
regulate_instance_connection { self }
5
end
Copied!
will attach the current acting user to the User channel (which is shared with all users) and to that user's private channel.
Both blocks execute with self set to the current acting user, but the return value has a different meaning. If regulate_class_connection returns any truthy value, then the class level connection will be made on behalf of the acting user. On the other hand, if regulate_instance_connection returns an array (possibly nested) or Active Record relationship then an instance connection is made with each object in the list. So, for example, you could add:
1
class User < ActiveRecord::Base
2
has_many chat_rooms
3
regulate_instance_connection { chat_rooms }
4
# we will connect to all the chat room channels we are members of
5
end
Copied!
To broadcast to all users, the Operation would have
1
dispatch_to { User } # dispatch to the User class channel
Copied!
or to send an announcement to a specific user
1
class PrivateAnnouncement < Hyperstack::ServerOp
2
param :receiver
3
param :message
4
# dispatch_to can take a block if we need to
5
# dynamically compute the channels
6
dispatch_to { params.receiver }
7
end
8
...
9
# somewhere else in the server
10
PrivateAnnouncement.run(receiver: User.find_by_login(login), message: 'log off now!')
Copied!
The above will work if PrivateAnnouncement is invoked from the server, but usually, some other client would be sending the message so the operation could look like this:
1
class PrivateAnnouncement < Hyperstack::ServerOp
2
param :acting_user
3
param :receiver
4
param :message
5
validate { raise Hyperstack::AccessViolation unless params.acting_user.admin? }
6
validate { params.receiver = User.find_by_login(receiver) }
7
dispatch_to { params.receiver }
8
end
Copied!
On the client::
1
PrivateAnnouncement.run(receiver: login_name, message: 'log off now!').fail do
2
alert('message could not be sent')
3
end
Copied!
and elsewhere in the client code, there would be a component like this:
1
class Alerts < Hyperstack::Component
2
include Hyperstack::Store::Mixin
3
# for simplicity we are going to merge our store with the component
4
state alert_messages: [] scope: :class
5
receives PrivateAnnouncement { |params| mutate.alert_messages << params.message }
6
render(DIV, class: :alerts) do
7
UL do
8
state.alert_messages.each do |message|
9
LI do
10
SPAN { message }
11
BUTTON { 'dismiss' }.on(:click) { mutate.alert_messages.delete(message) }
12
end
13
end
14
end
15
end
16
end
Copied!
This will (in only 28 lines of code)
  • associate a channel with each logged in user
  • invoke the PrivateAnnouncement Operation on the server (remotely from the client)
  • validate that there is a logged in user at that client
  • validate that we have a non-nil, non-blank receiver and message
  • validate that the acting_user is an admin
  • look up the receiver in the database under their login name
  • dispatch the parameters back to any clients where the receiver is logged in
  • those clients will update their alert_messages state and
  • display the message
The dispatch_to callback takes a list of classes, representing Channels. The Operation will be dispatched to all clients connected to those Channels. Alternatively dispatch_to 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 dispatch_to callback 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.
1
class Announcement < Hyperstack::Operation
2
param :message
3
param :duration
4
param to: nil, type: User
5
# dispatch to the Users channel only if specified otherwise announcement is application wide
6
dispatch_to { params.to || Hyperstack::Application }
7
end
Copied!

Defining Connections in ServerOps

The policy methods always_allow_connection and regulate_class_connection may be used directly in a ServerOp class. This will define a channel dedicated to that class, and will also dispatch to that channel when the Operation completes.
1
class Announcement < Hyperstack::ServerOp
2
# all clients will have an Announcement Channel which will
3
# receive all dispatches from the Announcement Operation
4
always_allow_connection
5
end
Copied!
1
class AdminOps < Hyperstack::ServerOp
2
# subclasses can be invoked from the client if an admin is logged in
3
# and all other clients that have a logged in admin will receive the dispatch
4
regulate_class_connection { acting_user.admin? }
5
param :acting_user
6
validate { param.acting_user.admin? }
7
end
Copied!

Regulating Dispatches in Policy Classes

Regulations and dispatch lists can be grouped and specified in Policy files, which are by convention kept in the Rails app/policies directory.
1
# app/policies/announcement_policy.rb
2
class AnnouncementPolicy
3
always_allow_connection
4
dispatch_to { params.acting_user }
5
end
6
7
# app/policies/user_policy.rb
8
class UserPolicy
9
regulate_instance_connection { self }
10
end
Copied!

Serialization

If you need to control serialization and deserialization across the wire you can define the following class methods:
1
def self.serialize_params(hash)
2
# receives param_name -> value pairs
3
# return an object ready for to_json
4
# default is just return the input hash
5
end
6
7
def self.deserialize_params(object)
8
# recieves whatever was returned from serialize_to_server
9
# (param_name => value pairs by default)
10
# must return a hash of param_name => value pairs
11
# by default this returns object
12
end
13
14
def self.serialize_response(object)
15
# receives the object ready for to_json
16
# by default this returns object
17
end
18
19
def self.deserialize_response(object)
20
# receives whatever was returned from serialize_response
21
# by default this returns object
22
end
23
24
def self.serialize_dispatch(hash)
25
# input is always key - value pairs
26
# return an object ready for to_json
27
# default just returns the input hash
28
end
29
30
def self.deserialize_dispatch(object)
31
# recieves whatever was returned from serialize_to_server
32
# (param_name => value pairs by default)
33
# must return a hash of param_name => value pairs
34
# by default this returns object
35
end
Copied!

Accessing the Controller

ServerOps have the ability to receive the "controller" as a param. This is handy for low-level stuff (like login) where you need access to the controller. There is a subclass of ServerOp called ControllerOp that simply declares this param and will delegate any controller methods to the controller param. So within a ControllerOp if you say session you will get the session object from the controller.
Here is a sample of the SignIn operation using the Devise Gem:
1
class SignIn < Hyperstack::ControllerOp
2
param :email
3
inbound :password
4
add_error(:email, :does_not_exist, 'that login does not exist') { !(@user = User.find_by_email(params.email)) }
5
add_error(:password, :is_incorrect, 'password is incorrect') { !@user.valid_password?(params.password) }
6
# no longer have to do this step { params.password = nil }
7
step { sign_in(:user, @user) }
8
end
Copied!
In the code above there is another parameter type in ServerOps, called inbound, which will not get dispatched.

Broadcasting to the current_session

Let's say you would like to be able to broadcast to the current session. For example, after the user signs in we want to broadcast to all the browser windows the user happens to have open so that they can update.
For this, we have a current_session method in the ControllerOp that you can dispatch to.
1
class SignIn < Hyperstack::ControllerOp
2
param :email
3
inbound :password
4
add_error(:email, :does_not_exist