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

The Structure of a Hyperloop Application

Mitch VanDuyn edited this page Jan 22, 2017 · 5 revisions

Hyperloop is not a Framework...

unless you want it to be.

Hyperloop is a collection of Ruby classes packaged in several gems so that you can quickly (and we mean quickly) create great applications, using just Ruby. These classes allow you to structure your code so that it's debuggable, reusable, maintainable, and best of all short and sweet. If you want to call that a framework, that is okay, but it's also useful to think about how each of the Hyperloop elements works as an independent part of the application. That way you can pick and choose how and when to use each piece of Hyperloop to your best advantage.

So without further ado, let's get stuck into the Structure of a Hyperloop Application.

Stores

Sitting at the base of your Application are one or more Stores. This is the same "Store" that Flux talks about. In Hyperloop, Stores are created by subclassing the HyperStore base class.

A HyperStore is just like any other Ruby class but with a very special feature: they have reactive instance variables or state variables, or simply state.

State variables literally hold the state of your application. State variables work just like regular Ruby instance variables, except that they intelligently inform any of your Components (we will get to them next) that they will need to re-render when the state changes.

Here is a very simple Store which keeps a list of Words. Note that we are only going to have a single Word store, so everything is defined at the class level.

class Word < HyperStore::Base
  class << self
    private_state list: []
    receives :add! do |word| 
      state.list! << word
    end
    def list
      state.list
    end
  end
end

The declaration private_state list: [], scope: :class creates a state variable called list, and initializes it to an empty array.

The declaration receives :add! do ... creates an action method that receives the add! action. The method will push a new word onto the list. We tell HyperStore that we are acting on the list by appending the exclamation to the state name. Likewise, by convention action methods names end in the exclamation as well.

To see our words we have the list method. Notice that list only reads the state so there is no ! either on our method name or when we access the state.

That is all you have to know about HyperStore. The rest is plain old Ruby code, plus some helper methods to save typing. You can create Stores as simple as the one above or as complex as one that keeps a pool of random users available for display like this example.

Components

Now that we have some state tucked away we will want to display it. In Hyperloop you create your views with Components which are Ruby classes that wrap React.js components. Let's make a simple component to display our Words, and allow the user to add more words to the list.

class ShowWords < React::Component::Base
  render(DIV) do
    INPUT(placeholder: 'enter a new word, and hit enter', style: {width: 200})
    .on(:key_down) do |evt|
      next unless evt.key_code == 13
      Word.add! evt.target.value
      evt.target.value = ''
    end
    UL do
      Word.list.each { |word| LI { word } }
    end
  end
end

Our component definition is straight forward. The ShowWords component renders a DIV which contains an input box, and a list of words.

The input box has a handler for the key_down event. We ignore all keys except the enter key. When the enter key is pressed, we dispatch the new word to Word's add! action, and clear the input.

To show the list of words we use Word's list method and display each word in the list.

As new words are added, our component will re-render because the value of list will have changed.

Hyperloop Components have all the features of normal React components and more. For the complete description of the DSL (domain specific language) and other details see the documentation.

Action Objects

So far in our example, we have defined the add! action directly in the Store. It is often useful to structure the actions as separate objects.

  • Actions defined as separate objects allow Components to be completely separate from the Store being updated, which can facilitate reuse.
  • Business logic, external API calls, and complex sequencing between stores can be packaged inside the Action.
  • Defining Actions as separate objects allows Stores to do one thing: manage their internal state.
  • As we will see Actions can be used to implement protected logic that should only run on the server.

In the classic Flux pattern, all actions are defined as separate entities. Hyperloop is less opinionated. In cases where the action is clearly associated with a single specific Store, then go right ahead and define it as an action in the store. If on the other hand, the Action has a general meaning which can be described without reference to a particular store or state, needs to run on the server or has complex business logic, then define an Action object.

Let's add a ClearAll action to our growing application. The meaning is for any store to reset itself to its initial state.

First we define the Action:

class ClearAll < HyperAction
end

Then let's have our Word store class listen for it and empty the list:

class Word < HyperStore::Base
  class << self
    receives ClearAll { state.list! [] }
  end
end

Finally, we will create a new component that uses our ShowWords component and adds a 'Reset' button:

class App < React::Component::Base
  render(DIV) do
    ShowWords()
    BUTTON { 'Reset' }.on(:click) { ClearAll() }
  end
end

I hope all this is clear: The ClearAll (no pun intended) Action breaks the coupling between the App component and the Word store. If we added another store that needed to respond to the ClearAll action only that store's code would change.

Custom Action Logic: The execute method

Every Action has an execute method. By default, this method will dispatch to all the receivers of the Action. For more complex actions we can define our own execute method. This concept is taken directly from the Trailblazer Operation class.

Here we will create an Action that adds a random word using setgetgo.com

class AddRandomWord < HyperAction
  param length: nil # length is optional and will default to nil
  def length
    "?len=#{params.length}" if params.length
  end
  def url
    "http://randomword.setgetgo.com/get.php#{length}"
  end
  def execute
    HTTP.get(url, dataType: :jsonp).then do |response|
      Word.add! response.json[:Word]
    end
  end
end

Our action simply makes the request to "getsetgo", and when the promise resolves will dispatch the new word to Word's add action.

Actions are where all complex business logic and API calls should go. Stores should be kept very simple, and have the minimum action methods needed to update the store.

Remote Actions

Let's say we want to keep our own list of words on the server, and we don't want to bring down the entire list of words when the client application starts.

Traditionally this would involve setting up our own internal API, and adding a controller (at least) to our server to deal with the API.

In Hyperloop it's so much easier:

class GetRandomWord < HyperAction
  allow_remote_execution 
  def self.words 
    @words ||= File.readlines("words_file.txt")
  end
  def execute
    AddRandomWord.words.sample
  end
end

The allow_remote_execution declaration indicates that AddRandomWord can be called remotely.

Models

If you want to persist a Store you could set up set up some remote Actions running on the server to read and write to your database. Certainly, it would be straightforward, but if you are already using Rails and ActiveRecord it's completely automatic.

Any of authorized ActiveRecord model data will be directly available on the client.

For example, let's convert our Word store to a Word model.

class Word < ActiveRecord::Base
  def self.add!(word)
    create(text: word)
  end
  def self.list
    all.pluck(:text)
  end
end

We included the add! action method and the list getter just so the interface would be consistent with our previous examples, both are implemented using the standard ActiveRecord methods create, all and pluck. Of course, you have to create a Rails migration to add the Word table to the database, but as far as coding we are done - well almost done - we still have to define the access policy for our model, but that is coming up next.

Using the ActiveRecord model definitions Hyperloop will construct all stores, and actions needed and will keep all local copies synchronized with the server database. This works not only for simple models and attributes, but also for relationships, and scopes.

Like Stores, your Models should be kept as simple as possible. Move any complex business logic to Actions. Models may contain nothing but some scopes, and relationships.

Policies

The Hyperloop ActiveRecord implementation will allow full access to your models from your client, not just create, but read, update and destroy. So Hyperloop also implements a policy system that prevents unauthorized or accidental access.

Access is based on the concept of the acting_user, and on channels. The acting_user represents the current logged in user. Channels represent a broadcast channel between the server and one or more browsers.

Here is a very simple policy that will create an Application channel attached to all browsers and another policy that will allow the Application channel to create, and read from the Word model.

class ApplicationPolicy
  # any browser may connect to the Application channel
  always_allow_connection
end

class WordPolicy
  # always allow creation of new words
  allow_create
  # broadcast all attributes of Word to the Application channel 
  regulate_broadcast { |policy| policy.send_all.to Application }
end

More realistically, of course, we might require a logged in user to create Words and allow administrators to delete words:

class WordPolicy
  allow_create { acting_user } 
  allow_destroy { acting_user.admin? }
  regulate_broadcast { |policy| policy.send_all.to Application }
end

When we defined our remote GetRandomWord Action we included the ability for any client to remotely invoke the Action. To restrict this to only logged in users we could add this line to the ApplicationPolicy:

  GetRandomWord.allow_remote_execution { acting_user }

This would require that we have a current acting_user in order to run the Action.

Summary

  • Your application state goes in Stores and Models. These should be kept as simple as possible.
  • Components display the state of your Stores and Models and bind user events to Actions.
  • All complex business logic and external API access should be grouped logically into Actions.
  • Actions may invoke other Actions either on the client or the server. Server-side actions can accomplish protected activities like accessing secure APIs.
  • If authorized your ActiveRecord models are directly accessible on the client. Changes to the database will be synchronized across all clients with permission to see the data.
  • Policies control remote action calls, and access to ActiveRecord models.
Clone this wiki locally