Shopify / tapioca

The swiss army knife of RBI generation
MIT License
746 stars 128 forks source link

Make `UrlHelpers` generator Rails Engine aware #474

Open paracycle opened 3 years ago

paracycle commented 3 years ago

It turns out that Rails Engines have their own router and the controllers, etc inside a Rails Engine get an engine specific url_helper module included, not the Rails.application one.

In order to be able to properly represent this, we need to do the following:

Test Case

module Article
  class Engine < ::Rails::Engine
    isolate_namespace Article

    routes.draw do
      resource :articles
    end
  end
end

class Application < Rails::Application
  routes.draw do
    resource :index
    mount Article::Engine, at: "/", as: "articles"
  end
end

should create:

# generated_path_helpers_module.rbi
# typed: strong

module GeneratedPathHelpersModule
  include ::ActionDispatch::Routing::UrlFor
  include ::ActionDispatch::Routing::PolymorphicRoutes

  sig { params(args: T.untyped).returns(String) }
  def edit_index_path(*args); end

  sig { params(args: T.untyped).returns(String) }
  def index_path(*args); end

  sig { params(args: T.untyped).returns(String) }
  def new_index_path(*args); end

  sig { params(args: T.untyped).returns(String) }
  def articles_path(*args); end
end
# generated_url_helpers_module.rbi
# typed: strong

module GeneratedUrlHelpersModule
  include ::ActionDispatch::Routing::UrlFor
  include ::ActionDispatch::Routing::PolymorphicRoutes

  sig { params(args: T.untyped).returns(String) }
  def edit_index_url(*args); end

  sig { params(args: T.untyped).returns(String) }
  def index_url(*args); end

  sig { params(args: T.untyped).returns(String) }
  def new_index_url(*args); end

  sig { params(args: T.untyped).returns(String) }
  def articles_url(*args); end
end
# article/engine/generated_path_helpers_module.rbi
# typed: strong

module Article::Engine::GeneratedPathHelpersModule
  include ::ActionDispatch::Routing::UrlFor
  include ::ActionDispatch::Routing::PolymorphicRoutes

  sig { params(args: T.untyped).returns(String) }
  def edit_article_path(*args); end

  sig { params(args: T.untyped).returns(String) }
  def article_path(*args); end

  sig { params(args: T.untyped).returns(String) }
  def new_article_path(*args); end

  sig { params(args: T.untyped).returns(String) }
  def articles_path(*args); end
end
# article/engine/generated_url_helpers_module.rbi
# typed: strong

module Article::Engine::GeneratedUrlHelpersModule
  include ::ActionDispatch::Routing::UrlFor
  include ::ActionDispatch::Routing::PolymorphicRoutes

  sig { params(args: T.untyped).returns(String) }
  def edit_article_url(*args); end

  sig { params(args: T.untyped).returns(String) }
  def article_url(*args); end

  sig { params(args: T.untyped).returns(String) }
  def new_article_url(*args); end

  sig { params(args: T.untyped).returns(String) }
  def articles_url(*args); end
end

and the corresponding includes to the modules that include the above helpers.

paracycle commented 3 years ago

It turns out that for the above test case also need to generate:

module GeneratedMountedHelpers < ActionDispatch::Routing::RouteSet::MountedHelpers
  sig { returns(Article::Engine::RoutesProxy) }
  def articles; end
end

with:

class Article::Engine::GeneratedRoutesProxy < ActionDispatch::Routing::Proxy
  include Article::Engine::GeneratedPathHelpersModule
  include Article::Engine::GeneratedUrlHelpersModule
end

and the GeneratedMountedHelpers module being mixed into all controllers as a helper module.

paracycle commented 3 years ago

Btw, the way to discover all this info about Rails engines is:

$ dev c
👩‍💻  Running bin/console from dev.yml
[1] pry(main)> require "rails"
=> true
[2] pry(main)> module Article
  class Engine < ::Rails::Engine
    isolate_namespace Article

    routes.draw do
      resource :articles
    end
  end
end

class Application < Rails::Application
  routes.draw do
    resource :index
    mount Article::Engine, at: "/", as: "articles"
  end
end
=> nil
[3] pry(main)> Rails.application.railties.grep(Rails::Engine)
=> [#<Article::Engine:0x00007f80193c0d48
  @_all_autoload_paths=nil,
  @_all_load_paths=nil,
  @app=nil,
  @app_build_lock=#<Thread::Mutex:0x00007f80193c0be0>,
  @config=
   #<Rails::Engine::Configuration:0x00007f80194f3c10
    @generators=
     #<Rails::Configuration::Generators:0x00007f80194f38a0
      @after_generate_callbacks=[],
      @aliases={},
      @api_only=false,
      @colorize_logging=true,
      @fallbacks={},
      @hidden_namespaces=[],
      @options={},
      @templates=[]>,
    @javascript_path="javascript",
    @middleware=#<Rails::Configuration::MiddlewareStackProxy:0x00007f80194f3648 @delete_operations=[], @operations=[]>,
    @root=#<Pathname:/Users/ufuk/src/github.com/Shopify/shopify-types>>,
  @env_config=nil,
  @helpers=nil,
  @routes=#<ActionDispatch::Routing::RouteSet:0x00007f80194f3490>>]
[4] pry(main)> Rails.application.railties.grep(Rails::Engine).first.routes.named_routes.path_helpers_module.instance_methods(false)
=> [:edit_articles_path, :new_articles_path, :articles_path]
[5] pry(main)> Rails.application.railties.grep(Rails::Engine).first.routes.named_routes.url_helpers_module.instance_methods(false)
=> [:articles_url, :edit_articles_url, :new_articles_url]
[6] pry(main)> Rails.application.routes.mounted_helpers.instance_methods(false)
=> [:_articles, :articles]
nickpoorman commented 4 days ago

Anyone have a solution for this?

nickpoorman commented 4 days ago

Here's the first part anyway. It satisfies my code at this point.

Also, for anyone trying to run this on an engine that isn't mounted, make sure you use the --app-root cli switch, i.e. tapioca dsl --app-root=test/dummy

Creates an engine.rbi for each engine with a GeneratedPathHelpersModule and GeneratedUrlHelpersModule scoped to the Engine.

# sorbet/tapioca/compilers/engine_url_helpers.rb
# typed: strict
# frozen_string_literal: true

module Tapioca
  module Dsl
    module Compilers
      class EngineUrlHelpers < Tapioca::Dsl::Compiler
        extend T::Sig

        ConstantType = type_member { { fixed: Module } }

        sig { override.returns(T::Enumerable[Module]) }
        def self.gather_constants
          # Gather all Rails::Engine subclasses, specifically targeting Engine(s)
          Rails::Engine.subclasses.select { |engine| engine.name.include?("::Engine") }
        end

        sig { override.void }
        def decorate
          # Generate all the methods on the engine router.
          has_routes = generate_helpers_rbi

          # Generate the Engine.routes sig.
          generate_engine_routes_rbi if has_routes
        end

        private

        sig { void }
        def generate_engine_routes_rbi
          root.create_path(constant) do |klass|
            klass.create_method(
              "routes",
              class_method: true,
              return_type: "ActionDispatch::Routing::RouteSet"
            )
          end
        end

        sig { returns(T::Boolean) }
        def generate_helpers_rbi
          routes = constant.routes
          path_helpers_module = routes.named_routes.path_helpers_module
          url_helpers_module = routes.named_routes.url_helpers_module

          path_instance_methods = path_helpers_module.instance_methods(false)
          url_instance_methods = url_helpers_module.instance_methods(false)

          unless path_instance_methods.empty?

            # Define the Path Helpers Module
            root.create_module("#{constant}::GeneratedPathHelpersModule") do |mod|
              mod.create_include("::ActionDispatch::Routing::UrlFor")
              mod.create_include("::ActionDispatch::Routing::PolymorphicRoutes")

              path_instance_methods.each do |method_name|
                mod.create_method(
                  method_name.to_s,
                  parameters: [create_rest_param("args", type: "T.untyped")],
                  return_type: "String"
                )
              end
            end
          end

          unless url_instance_methods.empty?

            # Define the URL Helpers Module
            root.create_module("#{constant}::GeneratedUrlHelpersModule") do |mod|
              mod.create_include("::ActionDispatch::Routing::UrlFor")
              mod.create_include("::ActionDispatch::Routing::PolymorphicRoutes")

              url_instance_methods.each do |method_name|
                mod.create_method(
                  method_name.to_s,
                  parameters: [create_rest_param("args", type: "T.untyped")],
                  return_type: "String"
                )
              end
            end
          end

          !path_instance_methods.empty? || !url_instance_methods.empty?
        end
      end
    end
  end
end

For example, this is what is generated for the Litestream engine.

# sorbet/rbi/dsl/litestream/engine.rbi

# typed: true

# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Litestream::Engine`.
# Please instead update this file by running `bin/tapioca dsl Litestream::Engine`.

class Litestream::Engine
  class << self
    sig { returns(ActionDispatch::Routing::RouteSet) }
    def routes; end
  end
end

module Litestream::Engine::GeneratedPathHelpersModule
  include ::ActionDispatch::Routing::UrlFor
  include ::ActionDispatch::Routing::PolymorphicRoutes

  sig { params(args: T.untyped).returns(String) }
  def process_path(*args); end

  sig { params(args: T.untyped).returns(String) }
  def restorations_path(*args); end

  sig { params(args: T.untyped).returns(String) }
  def root_path(*args); end
end

module Litestream::Engine::GeneratedUrlHelpersModule
  include ::ActionDispatch::Routing::UrlFor
  include ::ActionDispatch::Routing::PolymorphicRoutes

  sig { params(args: T.untyped).returns(String) }
  def process_url(*args); end

  sig { params(args: T.untyped).returns(String) }
  def restorations_url(*args); end

  sig { params(args: T.untyped).returns(String) }
  def root_url(*args); end
end