I have a Rails 7.1 app and multiple (Rails Engine) Gems with conflicting inflection rules.
There is Gem1::Api::Gem1Controller
under app/controllers/gem1/api/gem1_controller.rb
and Gem2::API::Gem2Controller
under app/controllers/gem2/api/gem2_controller.rb
.
As I understand it, Zeitwerk uses global autoloaders for the whole Rails app including all Rails Engines (which are more or less treated as part of the main app).
By default, Zeitwerk can load Gem1::Api::Gem1Controller
, but fails to load Gem2::API::Gem2Controller
, because the inflection rule for "api" => "API"
is missing.
However, using the "normal" approach of adding custom inflection rules will not work, since then it will fail to load Gem1::Api::Gem1Controller
because it expects to find Gem1::API::Gem1Controller
.
# config/initializers/zeitwerk.rb
Rails.application.autoloaders.each do |autoloader|
autoloader.inflector.inflect("api" => "API") # works for Gem2, but breaks Gem1 as a result
end
Is there any way to define inflection rules that include the whole namespace? Something like
# config/initializers/zeitwerk.rb
Rails.application.autoloaders.each do |autoloader|
autoloader.inflector.inflect("gem2/api/gem2_controller" => "Gem2::API::Gem2Controller")
end
Or maybe there is a way to define a per-Gem or per-Rails-Engine inflector? Keep in mind it still needs to work as a Rails Engine.
I found a solution for this problem via a custom Inflector. The regular Rails::Autoloader::Inflector#camelize
ignores its second argument, but incorporating the absolute filepath into the camelization enables me to use both Gem1::Api
and Gem2::API
.
# config/initializers/autoloaders.rb
module PathnameSuffixInflector
@overrides = {}
@max_overrides_depth = 0
def self.camelize(basename, abspath)
return @overrides[[basename]] || basename.camelize if @max_overrides_depth <= 1
filenames = Pathname.new(abspath).each_filename.to_a[0..-2] + [basename]
@max_overrides_depth.downto(1).each do |suffix_length|
suffix = filenames.last(suffix_length)
return @overrides[suffix] if @overrides.key?(suffix)
end
return basename.camelize
end
def self.inflect(overrides)
@overrides.merge!(overrides.transform_keys { Pathname.new(_1).each_filename.to_a })
@max_overrides_depth = @overrides.keys.map(&:length).max || 0
end
end
PathnameSuffixInflector.inflect("gem2/api" => "API")
Rails.application.autoloaders.each do |autoloader|
autoloader.inflector = PathnameSuffixInflector
end
EDIT:
The above solution is nice if all occurrences of conflicting inflection are namespaced, however, sometimes there is also a conflict on root level vs namespaced level.
In the above solution, there may be an inflection like "controllers/api" => "API"
which is really ugly because it goes "below" root directory level. For such case, I made an Inflector that inflects relative to each root directory:
# config/initializers/autoloaders.rb
class RootRelativeInflector
def initialize(autoloader, overrides = nil)
@autoloader = autoloader
@overrides = overrides || {}
end
def camelize(basename, abspath)
return basename.camelize if @overrides.empty?
# find root directory of currently loaded constant for lookup
closest_root_path = @autoloader.__roots.keys.select { abspath.start_with?(_1) }.max_by(&:length)
relative_path = abspath[(closest_root_path.length + 1)..-1].delete_suffix(".rb")
return @overrides[relative_path] || basename.camelize
end
end
Rails.application.autoloaders.each do |autoloader|
autoloader.inflector = RootRelativeInflector.new(autoloader, {
"api" => "API",
"gem2/api" => "API"
})
end