The list of challenges in my companion app finally shows each challenge’s difficulty and points. Five stars for the difficulty, and the word “point” agreeing with the number. Two small display methods, and a question I answer on autopilot in Rails: where do they go?

This series keeps twenty years of Rails reflexes switched on inside a Hanami 3 application, a small CTF challenge tracker, and records where they stop matching what the framework does. Most posts so far have been about Rails against Hanami. This one is about Hanami 2 against Hanami 3, and about what happens when the version you learned from is not the version you run.

A helper first, as in Rails

The first draft went where it would go in Rails, into a helper module:

def difficulty_stars(difficulty)
  ("★" * difficulty) + ("☆" * (5 - difficulty))
end

def points_label(points)
  points == 1 ? "1 point" : "#{points} points"
end

Hanami generates app/views/helpers.rb with a comment inviting you to add helpers there, and nothing needs registering. The template called difficulty_stars(challenge.difficulty) and the request spec went green on the first run. No friction, and no pluralize helper either, hence the ternary.

Then the presenter I would write

Two helpers that each take one attribute of the same object are where I stop writing helpers in Rails. I do not use a gem for this, except in the rare case where one brings something real. I usually write a small base class, then one presenter per thing I present. Stripped down, on this app’s example, it looks like this:

class BasePresenter
  def initialize(object, view)
    @object = object
    @view = view
  end

  def self.presents(name)
    define_method(name) { @object }
  end

  def method_missing(...)
    @view.send(...)
  end

  def respond_to_missing?(...)
    @view.respond_to?(...)
  end
end
class ChallengePresenter < BasePresenter
  presents :challenge

  def difficulty_stars
    tag.span(("★" * challenge.difficulty) + ("☆" * (5 - challenge.difficulty)), class: "stars")
  end
end

The template builds it explicitly, at the moment it needs it:

<% @challenges.each do |challenge| %>
  <% presenter = ChallengePresenter.new(challenge, self) %>
  <li><%= challenge.name %> <%= presenter.difficulty_stars %></li>
<% end %>

Rendered with Action View 8.1.4.1 in a small script, that gives what you would expect, and it also shows where an unknown method goes:

  <li>Stack Smash <span class="stars">★★★★☆</span></li>
presenter.name -> NoMethodError: undefined method 'name' for an instance of #<Class:0x00000001242c19b8>

tag works inside the presenter because anything the presenter does not know is sent to the view. The record is reached through the name given to presents, never implicitly, which is why presenter.name lands on the view and fails there.

The part that never turned up

Hanami’s name for that object is a part, and I had read enough of the 2.x guides to expect the trade: name the class after the exposure, and the view wraps the value for you. So I moved the two methods into app/views/parts/challenge.rb:

# auto_register: false
# frozen_string_literal: true

module CtfTracker
  module Views
    module Parts
      class Challenge < CtfTracker::Views::Part
        def difficulty_stars
          ("★" * difficulty) + ("☆" * (5 - difficulty))
        end

        def points_label
          points == 1 ? "1 point" : "#{points} points"
        end
      end
    end
  end
end

The template now says challenge.difficulty_stars. A unit spec built the part by hand with described_class.new(value: challenge) and passed. The request spec did not:

undefined method 'difficulty_stars' for an instance of CtfTracker::Structs::Challenge (attribute not loaded?)

That parenthesis comes from ROM, not from Hanami, and it sent me the wrong way for a minute. It suggests a column missing from the query, when the real news is in the class name before it: the template was holding the bare struct. No part had been built at all.

The obvious suspect was the lookup: a namespace the view does not search, or a class it cannot find. Rather than change configuration on a guess, I asked the view, in a script, for each link of the chain:

part namespace:       CtfTracker::Views::Parts
class it finds:       CtfTracker::Views::Parts::Challenge
is a part:            true
decorated by default: false
challenges decorated: false

The first three lines were my suspects, and all three are fine. The last two explain everything. The view knows where parts live, finds mine, accepts it as a part, and then never asks for it, because nothing told it to. The reason is in the hanami-view 3.0.0 changelog, under Changed:

- **BREAKING:** Default to undecorated exposures. (@timriley in #274)

    Decorate your exposures with the new `.decorate` method, or the `decorate: true` option for `.expose`. Default back to decorated exposures via `config.decorate_exposures = true`. 

Reconfiguring the namespace, the obvious fix, would have changed nothing.

One word in the view

The fix is one word, in the view class rather than the template:

class Index < CtfTracker::View
  decorate :challenges
  expose :sorts
  expose :filters
end

Run the same script against that view and the last line flips to challenges decorated: true. Everything else I knew about parts was still true: the collection exposure is singularised to find Parts::Challenge, and each item is wrapped. One default had moved under it.

The challenges list of the companion app, signed in as Ada: Stack Smash
  (pwn) with four stars out of five, 300 points, at Winter Warm-up, solved by
  Ada; Baby RSA (crypto), two stars, 150 points; Cookie Jar (web), one star,
  1 point; Heap of Trouble (pwn), five stars, 500 points. Plain unstyled
  HTML.

The list at 08-ask-for-the-part, with made-up data. The stars, the points and the solver all come from the part. The app has no styling at all: it is a sandbox for the framework, not a product.

The part also forwards what it does not know to the value it wraps, which is why difficulty and points work inside it without a prefix:

part.name             -> "Stack Smash"
part.difficulty_stars -> "★★★★☆"
part.helpers          -> CtfTracker::Views::Part::PartHelpers

Helpers are still there, through an explicit helpers object rather than through method_missing. Put next to my Rails presenter, the delegation runs the other way round: my presenter sends the unknown to the view and names the record, the part sends the unknown to the record and names the helpers.

Closer to my Rails habit than I expected

The irony is that Hanami 3 is closer to how I already work in Rails than the version I learned it from. My presenters do not appear by themselves: a template asks for one, by name, when it needs one. Hanami 3 still lets you switch the old default back on, so I did, on the same view, to see what it used to cost:

as committed (decorate :challenges)
  challenges CtfTracker::Views::Part
  sorts      Array
  filters    Hash
  each challenge: CtfTracker::Views::Parts::Challenge
with decorate_exposures = true
  challenges CtfTracker::Views::Part
  sorts      CtfTracker::Views::Part
  filters    CtfTracker::Views::Part
  each challenge: CtfTracker::Views::Parts::Challenge

With decoration on by default, an exposure that has no part of its own is wrapped in the base part class anyway, so the list of sort columns and the hash of current filters reach the template inside objects with nothing to add. Now only what the view asks for is wrapped, and the view says so in one place you can read.

I think the change is reasonable. The cost of decorating is now paid where decoration is wanted, and a view class tells you which of its exposures carry behaviour. What it does make harder is noticing a part you forgot to ask for: an undecorated exposure is the default, not an error, so the template keeps working until it calls a method that only the part has. The only symptom I got was a message from another library pointing at the database.

I tripped because my knowledge was one major version old, which is the ordinary condition of anyone who learns a framework from its guides and then upgrades it. The part, its spec and the one-word fix are in the companion app at 08-ask-for-the-part.

Comments, questions, or just a reaction?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.