Nodes
Table of contents
- Observing routes
- Responding
- Observing + Responding
- Return Types
- Empty
- String
- JSON
- RBX
- RBX + Antlers
- Calling other code
- Calling a class
- Injecting a dependency
- Arguments
- Route level args
- Render level args
- Parallelism [UNRELEASED]
- Siblings
- Children
- CRUD
- Single Responsibility
- Classic Controller
- Unit Testing
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
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