Class: RuboCop::Cop::Flexport::EngineApiBoundary
- Inherits:
-
RuboCop::Cop
- Object
- RuboCop::Cop
- RuboCop::Cop::Flexport::EngineApiBoundary
- 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.
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
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_checksum ⇒ Object
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: (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: (accessed_engine)) end end |