class RuboCop::Cop::Flexport::EngineApiBoundary

This cop prevents code outside of a Rails Engine from directly accessing the engine without going through an API. The goal is to improve modularity and enforce separation of concerns.

# Defining an engine's API

The cop looks inside an engine's `api/` directory to determine its API. API surface can be defined in two ways:

Both of these approaches can be used concurrently in the same engine. Due to Rails Engine directory conventions, the API directory should generally be located at eg `engines/my_engine/app/api/my_engine/api/`.

# Usage

This cop can be useful when splitting apart a legacy codebase. In particular, you might move some code into an engine without enabling the cop, and then enable the cop to see where the engine boundary is crossed. For each violation, you can either:

The cop detects cross-engine associations as well as cross-engine module access.

The cop will complain if you use FactoryBot factories defined in other engines in your engine's specs. You can disable this check by adding the engine name to `FactoryBotOutboundAccessAllowedEngines` in .rubocop.yml.

# Isolation guarantee

This cop can be easily circumvented with metaprogramming, so it cannot strongly guarantee the isolation of engines. But it can serve as a useful guardrail during development, especially during incremental migrations.

Consider using plain-old Ruby objects instead of ActiveRecords as the exchange value between engines. If one engine gets a reference to an ActiveRecord object for a model in another engine, it will be able to perform arbitrary reads and writes via associations and `.save`.

# Example `api/_legacy_dependents.rb` file

This file contains a burn-down list of source code files that still do direct access to an engine “under the hood”, without using the API. It must have this structure.

“`rb module MyEngine::Api::LegacyDependents

FILES_WITH_DIRECT_ACCESS = [
  "app/models/some_old_legacy_model.rb",
  "engines/other_engine/app/services/other_engine/other_service.rb",
]

end “`

# Example `api/_whitelist.rb` file

This file contains a list of modules that are allowed to be accessed by code outside the engine. It must have this structure.

“`rb module MyEngine::Api::Whitelist

PUBLIC_MODULES = [
  MyEngine::BarService,
  MyEngine::BazService,
  MyEngine::BatConstants,
]

end “`

# “StronglyProtectedEngines” parameter

The Engine API is not actually a network API surface. Method invocations may happen synchronously and assume they are part of the same transaction. So if your engine is using modules whitelisted by other engines, then you cannot extract your engine code into a separate network-isolated service (even though within a big Rails monolith using engines the cross-engine method call might have been acceptable).

The “StronglyProtectedEngines” parameter helps in the case you want to extract your engine completely. If your engine is listed as a strongly protected engine, then the following additional restricts apply:

(1) Any use of your engine's code by code outside your engine is

considered a violation, regardless of *your* _legacy_dependents.rb,
_whitelist.rb, or engine API module. (no inbound access)

(2) Any use of other engines' code within your engine is considered

a violation, regardless of *their* _legacy_dependents.rb,
_whitelist.rb, or engine API module. (no outbound access)

(Note: “EngineSpecificOverrides” parameter still has effect.)

# “EngineSpecificOverrides” parameter

This parameter allows defining bi-lateral private “APIs” between engines. See example in global_model_access_from_engine_spec.rb. This may be useful if you plan to extract several engines into the same network-isolated service.

@example

# bad
class MyService
  m = ReallyImportantSharedEngine::InternalModel.find(123)
  m.destroy
end

# good
class MyService
  ReallyImportantSharedEngine::Api::SomeService.execute(123)
end

@example

# bad

class MyEngine::MyModel < ApplicationModel
  has_one :foo_model, class_name: "SharedEngine::FooModel"
end

# good

class MyEngine::MyModel < ApplicationModel
  # (No direct associations to models in API-protected engines.)
end

Constants

MAIN_APP_NAME
MSG
STRONGLY_PROTECTED_CURRENT_MSG
STRONGLY_PROTECTED_MSG

Public Instance Methods

check_for_cross_engine_factory_bot_usage(node, factory_node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 210
def check_for_cross_engine_factory_bot_usage(node, factory_node)
  factory = factory_node.children[0]
  accessed_engine, model_class_name = factory_engines[factory]
  return if accessed_engine.nil? || !protected_engines.include?(accessed_engine)

  model_class_node = parse_ast(model_class_name)
  return if valid_engine_access?(model_class_node, accessed_engine)

  add_offense(node, message: message(accessed_engine))
end
check_for_cross_engine_rails_association(node, assocation_hash_args) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 199
def check_for_cross_engine_rails_association(node, assocation_hash_args)
  class_name_node = extract_class_name_node(assocation_hash_args)
  return if class_name_node.nil?

  accessed_engine = extract_model_engine(class_name_node)
  return if accessed_engine.nil?
  return if valid_engine_access?(node, accessed_engine)

  add_offense(class_name_node, message: message(accessed_engine))
end
external_dependency_checksum() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 221
def external_dependency_checksum
  checksum = engine_api_files_modified_time_checksum(engines_path)
  return checksum unless check_for_cross_engine_factory_bot?

  checksum + spec_factories_modified_time_checksum
end
on_const(node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 171
def on_const(node)
  return if in_module_or_class_declaration?(node)
  # There might be value objects that are named
  # the same as engines like:
  #
  # Warehouse.new
  #
  # We don't want to warn on these cases either.
  return if sending_method_to_namespace_itself?(node)

  accessed_engine = extract_accessed_engine(node)
  return unless accessed_engine
  return if valid_engine_access?(node, accessed_engine)

  add_offense(node, message: message(accessed_engine))
end
on_send(node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 188
def on_send(node)
  rails_association_hash_args(node) do |assocation_hash_args|
    check_for_cross_engine_rails_association(node, assocation_hash_args)
  end
  return unless check_for_cross_engine_factory_bot?

  factory_bot_usage(node) do |factory_node|
    check_for_cross_engine_factory_bot_usage(node, factory_node)
  end
end

Private Instance Methods

all_engines_camelized() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 273
def all_engines_camelized
  all_snake_case = Dir["#{engines_path}*"].map do |e|
    e.gsub(engines_path, '')
  end
  camelize_all(all_snake_case)
end
allowlisted?(node, engine) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 361
def allowlisted?(node, engine)
  allowlist = read_api_file(engine, :allowlist)
  allowlist = read_api_file(engine, :whitelist) if allowlist.empty?
  return false if allowlist.empty?

  depth = 0
  max_depth = 5
  while node&.const_type? && depth < max_depth
    full_const_name = remove_leading_colons(node.source)
    return true if allowlist.include?(full_const_name)

    node = node.parent
    depth += 1
  end

  false
end
camelize_all(names) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 280
def camelize_all(names)
  names.map { |n| ActiveSupport::Inflector.camelize(n) }
end
check_for_cross_engine_factory_bot?() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 438
def check_for_cross_engine_factory_bot?
  spec_file? &&
    factory_bot_enabled? &&
    !factory_bot_outbound_access_allowed_engines.include?(current_engine)
end
current_engine() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 329
def current_engine
  @current_engine ||= engine_name_from_path(processed_source.path)
end
disallowed_main_app_access?(node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 251
def disallowed_main_app_access?(node)
  strongly_protected_engine?(current_engine) && main_app_access?(node)
end
engine_name_from_path(file_path) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 333
def engine_name_from_path(file_path)
  return nil unless file_path&.include?(engines_path)

  parts = file_path.split(engines_path)
  engine_dir = parts.last.split('/').first
  ActiveSupport::Inflector.camelize(engine_dir) if engine_dir
end
engine_specific_override?(node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 399
def engine_specific_override?(node)
  return false unless overrides_for_current_engine

  depth = 0
  max_depth = 5
  while node&.const_type? && depth < max_depth
    module_name = node.source
    return true if overrides_for_current_engine.include?(module_name)

    node = node.parent
    depth += 1
  end
  false
end
engines_path() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 259
def engines_path
  path = cop_config['EnginesPath']
  path += '/' unless path.end_with?('/')
  path
end
extract_accessed_engine(node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 244
def extract_accessed_engine(node)
  return MAIN_APP_NAME if disallowed_main_app_access?(node)
  return nil unless protected_engines.include?(node.const_name)

  node.const_name
end
extract_class_name_node(assocation_hash_args) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 313
def extract_class_name_node(assocation_hash_args)
  return nil unless assocation_hash_args

  assocation_hash_args.each_pair do |key, value|
    # Note: The "value.str_type?" is necessary because you can do this:
    #
    # TYPE_CLIENT = "Client".freeze
    # belongs_to :recipient, class_name: TYPE_CLIENT
    #
    # The cop just ignores these cases. We could try to resolve the
    # value of the const from the source but that seems brittle.
    return value if key.value == :class_name && value.str_type?
  end
  nil
end
extract_model_engine(class_name_node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 306
def extract_model_engine(class_name_node)
  class_name = class_name_node.value
  prefix = class_name.split('::')[0]
  is_engine_model = prefix && protected_engines.include?(prefix)
  is_engine_model ? prefix : nil
end
factory_bot_enabled?() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 434
def factory_bot_enabled?
  cop_config['FactoryBotEnabled']
end
factory_bot_outbound_access_allowed_engines() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 429
def factory_bot_outbound_access_allowed_engines
  @factory_bot_outbound_access_allowed_engines ||=
    camelize_all(cop_config['FactoryBotOutboundAccessAllowedEngines'] || [])
end
factory_engines() click to toggle source

Maps factories to the engine where they are defined.

# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 445
def factory_engines
  @factory_engines ||= find_factories.each_with_object({}) do |factory_file, h|
    path, factories = factory_file
    engine_name = engine_name_from_path(path)
    factories.each do |factory, model_class_name|
      h[factory] = [engine_name, model_class_name]
    end
  end
end
in_engine_file?(accessed_engine) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 341
def in_engine_file?(accessed_engine)
  current_engine == accessed_engine
end
in_legacy_dependent_file?(accessed_engine) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 345
def in_legacy_dependent_file?(accessed_engine)
  legacy_dependents = read_api_file(accessed_engine, :legacy_dependents)
  # The file names are strings so we need to remove the escaped quotes
  # on either side from the source code.
  legacy_dependents = legacy_dependents.map do |source|
    source.delete('"')
  end
  legacy_dependents.any? do |legacy_dependent|
    processed_source.path.include?(legacy_dependent)
  end
end
main_app_access?(node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 255
def main_app_access?(node)
  node.const_name.start_with?(MAIN_APP_NAME)
end
message(accessed_engine) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 230
def message(accessed_engine)
  if strongly_protected_engine?(accessed_engine)
    format(STRONGLY_PROTECTED_MSG, accessed_engine: accessed_engine)
  elsif strongly_protected_engine?(current_engine)
    format(
      STRONGLY_PROTECTED_CURRENT_MSG,
      accessed_engine: accessed_engine,
      current_engine: current_engine
    )
  else
    format(MSG, accessed_engine: accessed_engine)
  end
end
overrides_by_engine() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 387
def overrides_by_engine
  overrides_by_engine = {}
  raw_overrides = cop_config['EngineSpecificOverrides']
  return overrides_by_engine if raw_overrides.nil?

  raw_overrides.each do |raw_override|
    engine = ActiveSupport::Inflector.camelize(raw_override['Engine'])
    overrides_by_engine[engine] = raw_override['AllowedModules']
  end
  overrides_by_engine
end
overrides_for_current_engine() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 414
def overrides_for_current_engine
  overrides_by_engine[current_engine]
end
protected_engines() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 265
def protected_engines
  @protected_engines ||= begin
    unprotected = cop_config['UnprotectedEngines'] || []
    unprotected_camelized = camelize_all(unprotected)
    all_engines_camelized - unprotected_camelized
  end
end
read_api_file(engine, file_basename) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 383
def read_api_file(engine, file_basename)
  extract_api_list(engines_path, engine, file_basename)
end
remove_leading_colons(str) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 379
def remove_leading_colons(str)
  str.sub(/^:*/, '')
end
sending_method_to_namespace_itself?(node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 284
def sending_method_to_namespace_itself?(node)
  node.parent&.send_type?
end
spec_factories_modified_time_checksum() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 455
def spec_factories_modified_time_checksum
  mtimes = factory_files.sort.map { |f| File.mtime(f) }
  Digest::SHA1.hexdigest(mtimes.join)
end
strongly_protected_engine?(engine) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 425
def strongly_protected_engine?(engine)
  strongly_protected_engines.include?(engine)
end
strongly_protected_engines() click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 418
def strongly_protected_engines
  @strongly_protected_engines ||= begin
    strongly_protected = cop_config['StronglyProtectedEngines'] || []
    camelize_all(strongly_protected)
  end
end
through_api?(node) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 357
def through_api?(node)
  node.parent&.const_type? && node.parent.children.last == :Api
end
valid_engine_access?(node, accessed_engine) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 288
def valid_engine_access?(node, accessed_engine)
  return true if in_engine_file?(accessed_engine)
  return true if engine_specific_override?(node)

  return false if strongly_protected_engine?(current_engine)
  return false if strongly_protected_engine?(accessed_engine)

  valid_engine_api_access?(node, accessed_engine)
end
valid_engine_api_access?(node, accessed_engine) click to toggle source
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 298
def valid_engine_api_access?(node, accessed_engine)
  (
    in_legacy_dependent_file?(accessed_engine) ||
    through_api?(node) ||
    allowlisted?(node, accessed_engine)
  )
end