How are slice routes intended to interact with HANAMI_SLICES?

a.k.a A Really Complicated Slice Use-Case

The ability the startup the application with certain slices switched off is a really neat feature, one that I was going to build myself right before discovering this in Hanami.

Some Background

I work on my company’s identity team responsible for IAM infrastructure for our customers, so our security requirements are pretty stringent. We also want to go above and beyond where possible.

We have successfully moved our SAML integration API over to Hanami (from dry-system), and now we want to introduce a new slice for OAuth. A future increment will probably introduce a SCIM API.

These are all different, but sort-of related concerns that share a lot of dependencies, we want to unify it as multiple slices in one project.

Our app is intended to run as different “personas” in production, as k8s deployments. So the SAML API is one deployment, and we will have two copies of the OAuth deployment (two isolated use-cases with different security concerns).

So, we want to deploy our Hanami app with different HANAMI_SLICES per deployment to only enable the functionality for that pod.

The Problem

The slice route helper raises SliceLoadError when the referenced slice is not present in SliceRegistrar. However, this is the intended state of affairs when it is being excluded by HANAMI_SLICES.

This seems to make this feature considerably less useful. I think there needs to be some middle ground between an incorrect slice name, and one that is being intentionally switched off.

I’m a little unsure how this would work best, however. I could simply swallow SliceLoadError in slice, but that would silently fail if you typo a slice name. I think the exception is useful, but I’m not certain how this should be avoided.

Doing something like

if Hanami.slice?(:foo)
  slice :foo, at: '/foo'
end

doesn’t work, because the Routes class is loaded before the slices are registered. It would need to be a lazy check, perhaps something like

slice :foo, at: '/foo', if: -> { Hanami.slice?(:foo) }

Another possibility is registering a null slice in SliceRegistrar that does nothing, but passes the presence check. That would need to be excluded from SliceRegistrar#each.

I’m betting it’s pretty unlikely anybody has run into this before, but maybe I’m not alone and someone has solved this for themselves?

Otherwise, I’m curious what everybody thinks.

@timriley To solve this issue, I tried the following:

config/routes.rb

# frozen_string_literal: true

module MyApp
  class Routes < Hanami::Routes
    slice :admin, at: "/admin" do
    end

    slice :metrics, at: "/metrics" do
    end
  end
end

Then I added the following file:

slices/admin/config/routes.rb

# frozen_string_literal: true

module Admin
  class Routes < Hanami::Routes
    get "/users/:id", to: "users.show"
  end
end

I was following this integration spec in Hanami.

But when I start the server, and visit the Home page, I can reproduce @alassek problem.

⚡ HANAMI_SLICES=admin be hanami server

In the browser I see:

Puma caught this error: Slice 'metrics' not found (Hanami::SliceLoadError)
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice_registrar.rb:39:in `block in []'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice_registrar.rb:38:in `fetch'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice_registrar.rb:38:in `[]'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice/routing/resolver.rb:28:in `find_slice'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice/routing/resolver.rb:34:in `to_slice'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice/router.rb:72:in `slice'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/routes.rb:76:in `public_send'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/routes.rb:76:in `block (2 levels) in build_routes'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/routes.rb:74:in `each'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/routes.rb:74:in `block in build_routes'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-router-2.0.2/lib/hanami/router.rb:90:in `instance_eval'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-router-2.0.2/lib/hanami/router.rb:90:in `initialize'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice/router.rb:29:in `initialize'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice.rb:954:in `new'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice.rb:954:in `load_router'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice.rb:721:in `block in router'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice.rb:720:in `synchronize'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice.rb:720:in `router'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice.rb:739:in `rack_app'
/Users/jodosha/.gem/ruby/3.2.1/gems/hanami-2.0.3/lib/hanami/slice.rb:758:in `call'
/Users/jodosha/.gem/ruby/3.2.1/gems/puma-6.0.2/lib/puma/configuration.rb:268:in `call'
/Users/jodosha/.gem/ruby/3.2.1/gems/puma-6.0.2/lib/puma/request.rb:93:in `block in handle_request'
/Users/jodosha/.gem/ruby/3.2.1/gems/puma-6.0.2/lib/puma/thread_pool.rb:340:in `with_force_shutdown'
/Users/jodosha/.gem/ruby/3.2.1/gems/puma-6.0.2/lib/puma/request.rb:92:in `handle_request'
/Users/jodosha/.gem/ruby/3.2.1/gems/puma-6.0.2/lib/puma/server.rb:429:in `process_client'
/Users/jodosha/.gem/ruby/3.2.1/gems/puma-6.0.2/lib/puma/server.rb:232:in `block in run'
/Users/jodosha/.gem/ruby/3.2.1/gems/puma-6.0.2/lib/puma/thread_pool.rb:147:in `block in spawn_thread'

Here’s the approach I’m taking for now:

require "hanami/slice/router"

class RouteResolver < Hanami::Slice::Routing::Resolver
  def excluding?(slice_name)
    if slices && slice_dirs.include?(slice_name)
      slice_name = slice_name.to_s
      slices.none? { _1 == slice_name }
    else
      false
    end
  end

  private

  def slices = slice.config.slices

  def slice_dirs
    @_slice_dirs ||=
      Dir[slice.root / "slices" / "*"]
      .select { |path| File.directory?(path) }
      .map { |path| File.basename(path).to_sym }
  end
end

module SliceRouting
  def slice(slice_name, ...)
    return if @resolver.excluding?(slice_name)

    super
  end
end

Hanami::Slice::Router.prepend SliceRouting

class App < Hanami::App
  config.router.resolver = RouteResolver
end

Thanks for raising this issue here, @alassek! (And thanks @jodosha for taking a first look at it too :pray:t3:)

Adam, I think you’re one of the first (if not the first) people to exercise this combination of features in Hanami 2, and you’ve definitely hit a rough edge here. Thanks for bearing with it, and I think this solution of yours is a good one for now!

When I built the conditional slice loading support (i.e. HANAMI_SLICES), I intentionally took a naïve approach, so as not to complicate the feature: if a slice is not loaded, then it’s as if it doesn’t exist. In some ways this is helpful, because it guarantees no code can be inadvertently loaded from that slice, but in others, like you’ve seen here, it’s much less helpful!

I’d definitely like people to be able to have a single routes definition that uses multiple slices, and which can also gracefully ignore any slices that have been excluded from loading. Your solution here is a good first step in this direction, so thanks for sharing it!

To fully build this out, I think perhaps we might need some kind of representation of slices that (a) have been defined, but (b) have been excluded from loading. Like a Hanami::Slice::Unloaded or similar. This way we could have our router resolver use first-class representations of this state rather than having to infer it indirectly.

Feels like this’d definitely be a good thing for 2.1 or thereabouts. And if you’re interested in working on this, I’d be very happy to support you! No pressure or expectations here, of course. We’ll get to it one way or another :slight_smile:

The problem with being on the bleeding edge is, you tend you cut yourself. :sunglasses:

Thanks for the input, I will open a formal issue on GitHub and start working on a PR.

1 Like

Here’s another wrinkle: colleague had HANAMI_SLICES set in local env and saw a bunch of failing tests. If the constants don’t get loaded, the tests blow up.

I think this further underscores the necessity of an “inactive slice” concept. Due to the nature of how Rspec works, this needs to be done with exclude_pattern rather than runtime skips.

Here’s my solution:

if (slices = ENV["HANAMI_SLICES"].to_s.split(",")).any?
  slice_names = Dir[Hanami.app.root / "slices" / "*"].map { Pathname(_1).basename.to_s }
  excluded    = slice_names - slices

  RSpec.configure { |config| config.exclude_pattern = "spec/slices/{#{excluded.join(",")}}/**/*.rb" }
end

This depends on putting your slice-specific tests in a known subdirectory (in this case, spec/slices/slice_name). I already did this to enable database isolation features.

Ah yes, nice pickup. Feels like we need a round of work investigation the intersection of conditional slice loading with any possible features that may enumerate or refer to particular slices.

BTW, regarding your code snippet, ENV["HANAMI_SLICES"] isn’t actually the canonical location of this config, that env var feeds into Hanami.app.config.slices, so if you wanted to make sure you were being watertight with your approach, I’d look to check that one instead.

I have the feeling I will be stepping on every rake in the grass ahead of you :sweat_smile:

I was aware of the slice configuration, but for some reason I thought it was not yet populated when this code executes. I see that I was mistaken, so I will use that instead.

Thank you for your service, @alassek! :laughing: