Nodes

Nodes are the flexible building blocks of your application. They can respond to a route request, or they can be called by another node. They can render a return value, or they can create an event. They are designed to be specific enough to observe events and return values, but generic enough to be split up to represent a complex application with its own patterns and structure.

Nodes can render HTML/JSON directly from the Ruby class (via RBX, similar to JSX) and render other nodes into the output using the syntax; <html><{ ChildNode }></html>.

Observing routes

After setting up a route, observe it to render a response:

class UserNode < LowNode
  observe '/:user_id'

  def render(event: RenderEvent)
    event.params[:user_id] # => '123'
  end
end

Note

Events decide which actions are called. Observers decide which actions are accepted.

The observe '/path' syntax is the simplest way to respond to a request. It observes a route and calls the render method... or the receive method if a body was sent in the original request. Constrain the HTTP Verbs via the route type.

The actions are split up this way so that you can have both receiving and responding methods in the same file, and... it just feels rightâ„¢... to send and receive. To be, or not to be, that is the question: Whether 'tis nobler in the mind to suffer the slings and arrows of outrageous fortune, or to take arms against a sea of troubles.

For all you HTTP nerds, you can have more flexibility with the syntax:

observe '/path' => :action

The HTTP request/verb becomes the corresponding event/action:

HTTP Verb Event Action
GET RenderEvent render/get
QUERY ReceiveEvent receive/query
POST ReceiveEvent receive/post
PUT ReceiveEvent receive/put
PATCH ReceiveEvent receive/patch
DELETE RenderEvent render/delete

The RenderEvent/ReceiveEvent will call render/receive first before falling back to an action matching the HTTP verb.

Tip

A ReceiveEvent is just like a RenderEvent except it also has a body attribute.

For example, a POST request to the '/feedback' route will call the post action/method:

class FormNode < LowNode
  observe '/feedback' => :post

  def post(event: ReceiveEvent)
    submit(event.body)
    Status[200]
  end
end

Note

If no method matches the event's action then nothing happens, the observer returns nil. You get nothing! You lose! Good day, sir! You stole Fizzy Lifting Drinks! You bumped into the ceiling which now has to be washed and sterilized. Raindeer will move on to the next observer.

See also: Observers

Responding

Caution

TODO: I built a whole framework and missed something important. The RenderEvent needs to have the params from RouteEvent brought over to it... Whoops.

Every node supports an initialize and a render method. The class is initialized first before then being rendered. You can access any instance variables or methods from the render method:

class UserNode < LowNode
  observe '/:id'

  # Business logic.
  def initialize(event: RouteEvent)
    @id = event.params[:id]
  end

  # Templating.
  def render
    <strong>ID:</strong> {@id}
  end
end

Observing + Responding

With the "on" syntax and the Route we can observe and respond in one fell swoop:

on Route[GET => '/'] do |request_event|
  "Response"
end

Return Types

Empty

As hinted to by the elaborate note above, if our node method returns nil or '' and there are no other observers then the router will default to a 404 response.

class MyNode < LowNode
  def render
    nil
  end
end

String

class MyNode < LowNode
  def render
    "Hello"
  end
end

JSON

Hash return values are converted to a JSON string.

class MyNode < LowNode
  def render
    {
      error: "This framework is too awesome, burns my eyes."
    }
  end
end

RBX

RBX is just HTML inside Ruby. Use .rbx as your file extension and now you can place HTML inside of render:

class MyNode < LowNode
  def render
    <strong>Hello</strong>
  end
end

RBX + Antlers

Antlers allows you to render nodes within nodes:

class ParentNode < LowNode
  def render
    <html><{ ChildNode }></html>
  end
end
class ChildNode < LowNode
  def render
    <strong>Hello</strong>
  end
end

Which outputs:

<html><strong>Hello</strong></html>

See also: Templating

Calling other code

Nodes are designed for intercepting the request-response layer and then handing off control to your domain-specific models, presenters and business logic.

Tip

When to use a node? If you're dealing with a route or responding with HTML then use a node. If not then you don't have to use one.

Calling a class

All classes in /app are autoloaded so you can call any class from any node:

# /app/business_directory/business_logic.rb
class BusinessLogic
  def self.metrics(company_id:)
    # Calculate a bunch of business metrics.
  end
end

# /app/business_directory/business_directory.rbx
class BusinessDirectory < LowNode
  observe '/:company_id'

  def initialize(event: RouteEvent)
    company_id = event.params[:company_id]
    @metrics = BusinessLogic.metrics(company_id:)
  end

  def render
    <{ Metrics metrics=@metrics }>
  end
end

Injecting a dependency

See: Dependencies

Arguments

Note

Node actions/methods called via observers/events have an omittable event: argument

Route level args

An event keyword argument is optionally available to all initialize and render arguments.

class UserNode < LowNode
  observe '/:user_id'

  def initialize(event: RenderEvent)
    event.params[:user_id] # => '123'
  end

  def render(event: RenderEvent)
    event.params[:user_id] # => '123'
  end
end

Render level args

If the node has been rendered by another node then any props passed to that node are available as keyword arguments in the node's initialize or render methods.

Passing props:

<{ MyNode my_var='Yes' }>

Receiving args:

class MyNode < LowNode
  def render(my_var:)
    <strong>{my_var}</strong>
  end
end

Outputs:

<strong>Yes</strong>

Parallelism [UNRELEASED]

Speed up CPU-bound work with parallelism. Thanks to the immutable nature of nodes we can process them in parallel using the <{ parallelize: }> syntax. Your data must be immutable or be able to be copied by Raindeer. This means reducing your reliance on global state, such as global dependency injection via Providers, in favour of local dependency injection via Plugs.

Note

IO-bound work like database queries are essentially parallel already, as multiple asynchronous requests will simultaneously wait for database results. However the CPU-bound processing of these results is not parallelized, so you'll still see a speed up... depending on what you're doing.

Siblings

def render
  <{ parallelize: }>
    # For Loop executed at the same time as UserNode.
    <{ for: user in: @users }>
      <{ UserNode user=user }>
    <{ :for }>

    # PostsNode executed at the same time as For Loop.
    <{ PostsNode }>
  <{ :parallelize }>
end

Children

def render
  # Each item in the loop is executed at the same time.
  <{ for: user in: @users :parallelize }>
    <{ UserNode user=user }>
  <{ :for }>
end

CRUD

CRUD (Create, Read, Update, Delete) is pretty standard in web applications. Raindeer replaces controllers and actions with events and actions. Instead of a central controller for disparate create, read, update and delete actions, you observe the appropriate route with a standalone node for each event/action that happens to that route.

At first this may seem like more boilerplate for each action... but consider that in a controller each action usually becomes bloated over time, with either fat controllers or fat models. This way you are forced to focus on "single-responsiblity" from the start. Over time your node will be able to handle increasingly complexity gracefully.

Tip

In Raindeer the node is the controller, the service object and the template all-in-one.

Single Responsibility

Route:

Raindeer.router do
  put '/:user_id/update'
end

Node:

class UserUpdater < LowNode
  observe '/:user_id/update'

  def receive(event: ReceiveEvent)
    update(user_id: event.params[:user_id], event.body[:attributes])
    Status[200]
  end

  private

  def update(user_id:, attributes:)
    DB[:users].where(id: user_id).update(**attributes)
  end
end

Classic Controller

Caution

Hey... psssh... hey you. Wanna write some bad code? Yeah come on it will be fun. Okay so we've got...

Raindeer is all about guiding you in the right direction, but I'm not going to fuck up your life if you need to do things a certain way. That's the benefit of composition, you can do it all kinds of ways. Okay... since you were going to do it anyway, here's how to replicate a controller one-to-one in Raindeer.

Route:

Raindeer.router do
  route [GET, POST, PUT, DELETE] => '/:user_id'
end

Node:

class UserController < LowNode
  observe '/:user_id'
  include Controller

  def get(event: RenderEvent)
    find(user_id: event.params[:user_id])
    UserNode.render(event:, user_id:)
  end

  def post(event: ReceiveEvent)
    create(user_id: event.params[:user_id], event.body[:attributes])  
    Status[200]
  end

  def put(event: ReceiveEvent)
    update(user_id: event.params[:user_id], event.body[:attributes])  
    Status[200]
  end

  def delete(event: RenderEvent)
    delete(user_id: event.params[:user_id])
    Status[200]
  end

  private

  def update(user_id:, attributes:)
    DB[:users].where(id: user_id).update(**attributes)
  end

  # Etc.
end

Unit Testing

Note

Nodes use the Method Factory pattern. You call the class method to call the instance method.

Instead of calling new on a node class directly, first you call a class method which instantiates the class on your behalf, then calls the corresponding instance method.

Say your class looks like this:

class ListNode < LowNode
  def render
    <ul>...</ul>
  end
end

To call the #render instance method you first call the .render class method:

RSpec.describe ListNode do
  describe '#render' do
    it 'renders a list' do
      expect(subject.render).to eq('<ul>...</ul>')
    end
  end
end