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:
-
Add source files to `api/`. Code defined in these modules will be accessible outside your engine. For example, adding `api/foo_service.rb` will allow code outside your engine to invoke eg `MyEngine::Api::FooService.bar(baz)`.
-
Create an `_allowlist.rb` or `_whitelist.rb` file in `api/`. Modules listed in this file are accessible to code outside the engine. The file must have this name and a particular format (see below).
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:
-
Expose new API surface from your engine
-
Move the violating file into the engine
-
Add the violating file to `_legacy_dependents.rb` (see below)
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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 280 def camelize_all(names) names.map { |n| ActiveSupport::Inflector.camelize(n) } end
# 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
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 329 def current_engine @current_engine ||= engine_name_from_path(processed_source.path) end
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 434 def factory_bot_enabled? cop_config['FactoryBotEnabled'] end
# 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
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
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 341 def in_engine_file?(accessed_engine) current_engine == accessed_engine end
# 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
# 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
# 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
# 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
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 414 def overrides_for_current_engine overrides_by_engine[current_engine] end
# 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
# 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
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 379 def remove_leading_colons(str) str.sub(/^:*/, '') end
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 284 def sending_method_to_namespace_itself?(node) node.parent&.send_type? end
# 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
# File lib/rubocop/cop/flexport/engine_api_boundary.rb, line 425 def strongly_protected_engine?(engine) strongly_protected_engines.include?(engine) end
# 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
# 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
# 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
# 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