Class: RuboCop::Cop::Flexport::EngineApiBoundary

Inherits:
RuboCop::Cop
  • Object
show all
Includes:
EngineApi, EngineNodeContext
Defined in:
lib/rubocop/cop/flexport/engine_api_boundary.rb

Overview

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 a ‘_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.

# 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.

Examples:


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

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

# 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

Constant Summary collapse

MSG =
'Direct access of %<accessed_engine>s engine. ' \
'Only access engine via %<accessed_engine>s::Api.'
STRONGLY_PROTECTED_MSG =
'All direct access of ' \
'%<accessed_engine>s engine disallowed because ' \
'it is in StronglyProtectedEngines list.'
STRONGLY_PROTECTED_CURRENT_MSG =
'Direct ' \
'access of %<accessed_engine>s is disallowed in this file ' \
'because it\'s in the %<current_engine>s engine, which ' \
'is in the StronglyProtectedEngines list.'
MAIN_APP_NAME =
'MainApp::EngineApi'

Constants included from EngineApi

EngineApi::API_FILE_DETAILS

Instance Method Summary collapse

Methods included from EngineNodeContext

#in_module_or_class_declaration?

Methods included from EngineApi

#engine_api_files_modified_time_checksum, #extract_api_list

Instance Method Details

#external_dependency_checksumObject



193
194
195
# File 'lib/rubocop/cop/flexport/engine_api_boundary.rb', line 193

def external_dependency_checksum
  engine_api_files_modified_time_checksum(engines_path)
end

#on_const(node) ⇒ Object



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/rubocop/cop/flexport/engine_api_boundary.rb', line 163

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) ⇒ Object



180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/rubocop/cop/flexport/engine_api_boundary.rb', line 180

def on_send(node)
  rails_association_hash_args(node) do |assocation_hash_args|
    class_name_node = extract_class_name_node(assocation_hash_args)
    next if class_name_node.nil?

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

    add_offense(class_name_node, message: message(accessed_engine))
  end
end