May was a breakthrough month in terms of the integration of the standalone components into Hanami 2. Let’s dig right in.
Last month I wrote about my first pass at integrating Hanami view classes with application they exist within. It looked like this:
module Main # "Base" view class for `main` slice class View < Hanami::View[:main] end module Views class Articles # Class for a specific view, inheriting from base class Index < Main::View end end end end
In this approach, inheriting from
Hanami::View[:main] would tell the subclass to apply its configuration using details from the
main slice. It worked fine, but there’s still some redundancy there:
module Main # <- We're clearly in the `main` slice class View < Hanami::View[:main] # <- So why do we have to repeat it here? end end
In trying to get this stuff done for the next alpha release, Luca has definitely been encourating a pragmatic approach to getting things in place (“perfect is the enemy of good,” or in this case “shipped”). It’s the right way to go, but even still, this nagged at me, especially given our goal of reducing boilerplate as much as possible.
After a while I realised that the application itself could provide a facility to help us out in this situation. Given a class like
Main::View, and given that each slice ”owns” a specific namespace, there is enough information in the class name alone to infer which slice it belongs to. So now we have this:
Hanami.application.component_provider(Main::View) #=> #<Hanami::Slice:0x00007fc2ae074568 # @application=Soundeck::Application, # @booted=true, # @container=Main::Container, # @name=:main, # @namespace=Main, # @namespace_path="main", # @root=#<Pathname:/Users/tim/Source/hanami/soundeck/slices/main>>
Pass in a class or instance, and get its slice. Easy. With this in place, we can use it from the
.inherited hook of
Hanami::View to get all the information we need for truly seamless integration:
module Main class View < Hanami::View end end
Look, zero boilerplate!
But this is only half of the seamless view integration story: now that we can infer the slice that provides a given view, how do we add the slice-specific behaviour, especially given we’re still inheriting directly from
Hanami::View, which still needs to be able to provide the behaviour for standalone (non-integrated) views?
Our original approach for this was also pragmatic: when we need a subclass of a given Hanami component (like
Hanami::Action) to behave differently within an Hanami application versus when used standalone, we would just monkey patch the application-specific behaviour. Now, anyone who knows me would know this isn’t approach I would not tolerate for long. 😉 Even still, I was willing to do it for the sake of expedience. You can see the approach (and all my misgivings about it) in
lib/hanami/action/extensions/application_action.rb in my proof of concept action integration PR.
But as is the theme of this section, it nagged at me. What we really needed here was for the patched methods providing the integration specialisations to be able to call
super to get to the standalone behaviour wherever they needed. With a monkey patch, this isn’t possible because you end up completely replacing the methods (or having to resort to hacky “alias method chain”-style approaches).
One way to solve this would be to have a deeper inheritance chain (
Hanami::ApplicationView < Hanami::View) and using different superclasses for integrating views versus standalone views, but that bifurcates view usage in an unfriendly way, and more likely than not would make one of those two use cases more awkward than the other.
At this point I realised Ruby gives us another option for this: modules! What we needed here were two different modules in the ancestor chain for a given view class, with the “nearest” one providing the application integration behaviour (e.g.
[Main::Views::Articles::Index, Main::View, ApplicationView, StandaloneView]), and the next one back providing the standard standalone behaviour. This way, the application integration module can add only the specialisations it requires, and can call
super whenever it needs.
The final piece to this puzzle is to make it so that the
ApplicationView module can provide behaviour that’s specific to a given slice. This is where the module builder pattern comes in. Instead of this
ApplicationView module being a plain old static module, we can initialize it with the slice object that we get when we’re subclassing
Hanami::View in the first place.
So with this in place, here’s what
Hanami::View and its
.inherited roughly look like:
require_relative "view/application_view" require_relative "view/standalone_view" # ... module Hanami class View include StandaloneView # ... def self.inherited(subclass) super # If inheriting directly from Hanami::View within an Hanami app, configure # the view for the application if subclass.superclass == View && (provider = application_provider(subclass)) subclass.include ApplicationView.new(provider) end end def self.application_provider(subclass) if Hanami.respond_to?(:application?) && Hanami.application? Hanami.application.component_provider(subclass) end end private_class_method :application_provider end end
And the resulting ancestors for an actual view class:
Main::Views::Articles::Index.ancestors # => [ # Main::Views::Home::Index, # Main::View, # #<Hanami::View::ApplicationView[main]>, # Hanami::View, # Hanami::View::StandaloneView::InstanceMethods, # Hanami::View::StandaloneView, # # ... # ]
These are exactly the number of different places we need to neatly slot in all the behaviour for our truly seamless view integration!
The resulting arrangement has some other nice benefits, too, because the integration logic has now moved out from the hanami gem and over into the hanami-view gem itself:
I was chuffed with how this all worked out, and I’m much, much happier with the overall arrangement now. Hats off to Ruby for being such a flexible language! Check out the full hanai-view PR for this new integration approach, as well as the corresponding integration hooks (and reduction in view integration code!) inside the hanami gem.
A small subtle thing you might have noticed above was that check for
Hanami.application?. This is another hook I added to make it easier for components to integrate (or not) with an Hanami application. Because many of the Hanami components can be used on their own (hanami-view, hanami-router, and hanami-controller in particular), the
Hanami namespace will definitely exist, but not necessarily a full
Hanami.application? check provides a safe way to determine if an application has been defined before activating any integration code.
Right now this is defined directly on the
Hanami module by the hanami gem, but we’ll also be adding it to hanami-utils so you can safely use it without having to require the full application gem.
All the polishing of the view integration was a warm-up for the main game this month: properly implementing the integration of view rendering into Hanami actions.
This was the approach we agreed upon after our experiments last month:
class Index < Main::Action include Deps[view: "views.articles.index"] def handle(req, res) # Views are rendered by the response res.render view end end
And as of a few days ago, the work is complete!
res.render view belies a lot of underlying logic. What we do with this integration is provide the view with all the request-specific data that it might need to render itself, things like the current session, flash messages, CSRF token, etc. This is all set up automatically for you as soon as you inherit from
Hanami::Action within an existing Hanami application.
Sound familiar? That’s because we follow the exact same integration approach for actions as we do for views. Hanami::Action now has a
StandaloneAction module providing the basic functionality, and an
ApplicationAction module that is initialized with the action’s slice, so it can pick up whatever details it needs from the slice or application to provide the view rendering integration.
The crux of the integration is the action setting up a view context object with the request/response pair created when the action is called. This view context is automatically passed to the view when
res.render is called. Having the request/response pair available to the context means that the context object can provide methods to make those details like the
flash available for use within the view templates, scopes, and parts (these are links to dry-view documentation, since at this point, hanami-view unstable and dry-view 0.7 are effectively the same).
This integration is eminently flexible. There are multiple points at which an application author can customise it. The first would be to add their own methods to the view context class within their application (I’ll show examples of this this next month). The next would be to override any of these methods within their base action class:
#view_options, to pass additional options to every view as it is rendered
#view_context_options, to pass additional options to the view context
#build_response, to customize the response object that is prepared for passing to the action’s
#handlemethod (it’s unlikely this will need to be customised, but it’s nice to keep all options open)
I think these methods are a perfect example of the approach we’re taking with Hanami 2 development: conveniences by default, but every possible measure available to adjust things when you need to diverge from the defaults.
This month was all about laying the proper groundwork for action and view integration. With this done, my plan for June is to roll through all these steps to round it out:
Hanami::Actionconfiguration class-based, like we have for view configuration, so we can auto-configure actions based on their slice
Actions::Articles::Index) so you don’t need to explicitly auto-inject them
#handlefor basic render-only actions
Hanami::View::ApplicationContextcontext class with a default set of helpful methods for use within all Hanami views, including those that require access to the request (as described above)
With Hanami 2, while we’re building upon many years worth of open source efforts in terms of the existing libraries we’re pulling together, it’s become very clear to me through this process that doing good integration work is just as much effort all over again. Thanks for your patience while we work through this as best we can.
And a big shout out to Benjamin Klotz for your continuing support 😄
This post turned out to be a big one! The fact that I had so much to say speaks to just how pivotal a month this was. I’m looking forward to the next few weeks of rolling downhill from here and collecting a bunch of quick wins. See you all again at the end of June! 👋🏼