feature_pack 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c48de284c1d9224798b073645f3a80e923d4e72445272641f644e8ed15bb6bf
4
- data.tar.gz: 9c8c489303a5f0a878c756abbc6abb03b8c6073e3cb8a1beda43eeaba0705365
3
+ metadata.gz: 4ad6b0b7d1fef825b25714c0b54b468935b5d30bbb45b1e1cdaa2f7023417345
4
+ data.tar.gz: 795e0c96835d48091f3e50f0d37add2cd0b0a459a65fc592c2be527550ac3519
5
5
  SHA512:
6
- metadata.gz: 76ac5cdd3bcb0a241cbc40cd12ad11edbf94e267142e5d2a0e30e26a4b71462c9ae2cc58f0244034ec8d862d7ae8a67f70047273540eab4592e195e6085ca7fe
7
- data.tar.gz: f021bce17beadad88f4501768d2021e65e2393db17cd625053ad51e0cfff334555041d055c867517317f52cf79f78b647fe1fa38620f0427c112f8184094e6c1
6
+ metadata.gz: 0cfcaddc9bdb103bafa8be27d9c24d6034455660cac7077d58f11abf7faf095f705869fa534ded2efa02aa574f52437d871eae361c877fa6a0ab35c7ed7de51e
7
+ data.tar.gz: 7bc13892f86bb7f7b720da3aa79bcdc85e482fbaa2b1e3489d96947145dd773e725dd0e170257d2e6f42c775126267313e4095089fbc4e0af691a8e206eb541d
data/README.md CHANGED
@@ -152,6 +152,31 @@ class FeaturePack::HumanResources::EmployeesController < FeaturePack::HumanResou
152
152
  end
153
153
  ```
154
154
 
155
+ Generated feature controllers inherit their group's controller, so group callbacks
156
+ (authentication, authorization, etc.) apply to every feature. The setup callback
157
+ resolves the context from the routed path (`feature_pack/<group>/<feature>`): when the
158
+ second segment is a registered feature the request receives `@group` and `@feature` and
159
+ uses the feature's views; otherwise it receives `@group` and the group's views. No
160
+ extra declaration is needed in feature controllers.
161
+
162
+ Feature controllers that don't need a group controller can still inherit
163
+ `FeaturePack::Controller`, which requires the path to name a registered feature.
164
+
165
+ The private hooks `set_view_lookup_context_prefix` and `set_layout_paths` can be
166
+ overridden in a group controller and apply to both group and feature requests.
167
+
168
+ #### Migrating from 0.10.x
169
+
170
+ - To share group callbacks, change a feature controller's superclass from
171
+ `FeaturePack::Controller` to `FeaturePack::<Group>Controller`.
172
+ - `__after_initialize.rb` hooks are no longer loaded. Move that code to a Rails
173
+ initializer or to the group/feature controller and delete the hook files. Leftover
174
+ files are ignored by Zeitwerk, but they are dead code.
175
+ - `FeaturePack::API::Controller` was removed; inherit `ActionController::API` directly.
176
+ - `FeaturePack.setup` now fails when `app/feature_packs` does not exist. Create the
177
+ directory, or call `FeaturePack.setup(require_features_path: false)` to boot with no
178
+ groups (a warning is logged).
179
+
155
180
  ## Routes
156
181
 
157
182
  Routes are automatically configured based on manifest files:
@@ -247,68 +272,6 @@ Access aliased constants:
247
272
  @feature.service # => FeaturePack::HumanResources::Employees::EmployeeService
248
273
  ```
249
274
 
250
- ## Hooks
251
-
252
- ### after_initialize Hook
253
-
254
- O FeaturePack suporta hooks `after_initialize` que permitem executar código customizado após o carregamento de grupos e features.
255
-
256
- #### Como Funciona
257
-
258
- Durante o processo de setup do FeaturePack, após todos os grupos e features serem descobertos e configurados, o sistema procura e executa arquivos `__after_initialize.rb` específicos.
259
-
260
- #### Localização dos Arquivos
261
-
262
- - **Para grupos**: `app/feature_packs/[nome_do_grupo]/_group_space/__after_initialize.rb`
263
- - **Para features**: `app/feature_packs/[nome_do_grupo]/[nome_da_feature]/__after_initialize.rb`
264
-
265
- #### Contexto de Execução
266
-
267
- Os arquivos `__after_initialize.rb` são executados no contexto do objeto group ou feature, permitindo acesso direto a todas as suas propriedades através de `self`.
268
-
269
- #### Exemplos de Uso
270
-
271
- **Hook para grupo:**
272
- ```ruby
273
- # app/feature_packs/group_241209_human_resources/_group_space/__after_initialize.rb
274
-
275
- # Registrar o grupo em um sistema de auditoria
276
- Rails.logger.info "Grupo #{name} carregado com #{features.size} features"
277
-
278
- # Configurar permissões globais do grupo
279
- features.each do |feature|
280
- Rails.logger.info " - Feature #{feature.name} disponível em #{feature.manifest[:url]}"
281
- end
282
-
283
- # Carregar configurações específicas do grupo
284
- config_file = File.join(absolute_path, '_group_space', 'config.yml')
285
- if File.exist?(config_file)
286
- @config = YAML.load_file(config_file)
287
- end
288
- ```
289
-
290
- **Hook para feature:**
291
- ```ruby
292
- # app/feature_packs/group_241209_human_resources/feature_241209_employees/__after_initialize.rb
293
-
294
- # Registrar rotas dinâmicas
295
- Rails.logger.info "Feature #{name} inicializada no grupo #{group.name}"
296
-
297
- # Verificar dependências
298
- required_gems = %w[devise cancancan]
299
- required_gems.each do |gem_name|
300
- unless Gem.loaded_specs.key?(gem_name)
301
- Rails.logger.warn "Feature #{name} requer a gem #{gem_name}"
302
- end
303
- end
304
-
305
- # Registrar a feature em um sistema de métricas
306
- StatsD.increment("features.#{group.name}.#{name}.loaded") if defined?(StatsD)
307
-
308
- # Configurar cache específico da feature
309
- Rails.cache.write("feature:#{group.name}:#{name}:loaded_at", Time.current)
310
- ```
311
-
312
275
  ## Best Practices
313
276
 
314
277
  1. **Group Organization**: Group related features that share common functionality
@@ -356,4 +319,14 @@ Gedean Dias - gedean.dias@gmail.com
356
319
 
357
320
  - [GitHub Repository](https://github.com/gedean/feature_pack)
358
321
  - [RubyGems](https://rubygems.org/gems/feature_pack)
359
- - [Bug Reports](https://github.com/gedean/feature_pack/issues)
322
+ - [Bug Reports](https://github.com/gedean/feature_pack/issues)
323
+
324
+ ## Tests
325
+
326
+ ```bash
327
+ bundle install
328
+ bundle exec rspec
329
+ ```
330
+
331
+ The suite covers discovery, setup rollback, first-time generation, duplicate
332
+ protection, inherited authorization, and Rails request rendering using ERB fixtures.
data/doc/feature_pack.md CHANGED
@@ -30,7 +30,7 @@ The module defines several read-only attributes:
30
30
 
31
31
  The `setup` method initializes the FeaturePack library:
32
32
 
33
- 1. Validates the provided `features_path`
33
+ 1. Resolves `features_path` to `Rails.root/app/feature_packs` and validates it (missing directory raises unless `require_features_path: false`)
34
34
  2. Sets up ignored paths
35
35
  3. Discovers and initializes groups and features
36
36
  4. Sets up routes and controllers for groups and features
@@ -38,7 +38,7 @@ The `setup` method initializes the FeaturePack library:
38
38
  ### Usage
39
39
 
40
40
  ```ruby
41
- FeaturePack.setup(features_path: '/path/to/features')
41
+ FeaturePack.setup
42
42
  ```
43
43
 
44
44
  ## Groups
@@ -1,62 +1,9 @@
1
- # Base controller for all feature controllers
2
- # Handles the setup of features, views, and layouts
1
+ require_relative 'feature_controller_setup'
2
+
3
+ # Base controller for features without a custom group controller.
3
4
  class FeaturePack::Controller < ApplicationController
5
+ include FeaturePack::FeatureControllerSetup
4
6
  prepend_before_action :setup_feature
5
7
 
6
- # Default index action
7
8
  def index; end
8
-
9
- private
10
-
11
- # Main setup method that configures the feature environment
12
- def setup_feature
13
- set_group_and_feature
14
- set_view_lookup_context_prefix
15
- set_layout_paths
16
- end
17
-
18
- # Extracts and sets the group and feature from the controller path
19
- def set_group_and_feature
20
- group_name, feature_name = params['controller']
21
- .delete_prefix('feature_pack/')
22
- .split('/')
23
- .map(&:to_sym)
24
-
25
- @group = FeaturePack.group(group_name)
26
- @feature = FeaturePack.feature(group_name, feature_name)
27
-
28
- raise FeaturePack::Error::NoGroup, "Group '#{group_name}' not found" if @group.nil?
29
- raise FeaturePack::Error::NoDataError, "Feature '#{feature_name}' not found in group '#{group_name}'" if @feature.nil?
30
- end
31
-
32
- # Configures the view lookup path to include feature-specific views
33
- def set_view_lookup_context_prefix
34
- return if lookup_context.prefixes.include?(@feature.views_relative_path)
35
-
36
- lookup_context.prefixes.prepend(@feature.views_relative_path)
37
- end
38
-
39
- # Sets up header and footer layout paths with fallback logic
40
- # Search order:
41
- # 1. Feature-specific partials
42
- # 2. Group-level partials (fallback)
43
- # 3. Application default (if neither exists)
44
- def set_layout_paths
45
- feature_partials_path = @feature.views_relative_path.join('partials')
46
- group_partials_path = @feature.group.views_path.concat('/partials')
47
-
48
- # Set header layout
49
- if template_exists?('header', feature_partials_path, true)
50
- @header_layout_path = @feature.view('partials/header')
51
- elsif template_exists?('header', group_partials_path, true)
52
- @header_layout_path = @feature.group.view('partials/header')
53
- end
54
-
55
- # Set footer layout
56
- if template_exists?('footer', feature_partials_path, true)
57
- @footer_layout_path = @feature.view('partials/footer')
58
- elsif template_exists?('footer', group_partials_path, true)
59
- @footer_layout_path = @feature.group.view('partials/footer')
60
- end
61
- end
62
9
  end
@@ -0,0 +1,111 @@
1
+ require_relative '../feature_pack'
2
+
3
+ # Request setup shared by group controllers, feature controllers that inherit
4
+ # their group controller, and standalone feature controllers
5
+ # (FeaturePack::Controller).
6
+ #
7
+ # The group and feature are resolved from the routed controller path
8
+ # ("feature_pack/<group>[/<feature>]"). When the second segment names a
9
+ # registered feature the request gets feature context (@group and @feature);
10
+ # otherwise it gets group context (@group only). No opt-in is required, so a
11
+ # controller can never silently receive the wrong setup.
12
+ module FeaturePack::FeatureControllerSetup
13
+ ROUTE_NAMESPACE = 'feature_pack/'.freeze
14
+ PROTECTED_IVARS = %i[@_feature_pack_prefixes].freeze
15
+
16
+ # Implicit rendering and the lookup context both read `_prefixes`.
17
+ # Override it per instance so a request-specific prefix never leaks into the
18
+ # controller class' memoized array (which subclasses share by reference).
19
+ def _prefixes
20
+ @_feature_pack_prefixes || super
21
+ end
22
+
23
+ # Keep the internal prefix array out of view assigns
24
+ def _protected_ivars
25
+ super + PROTECTED_IVARS
26
+ end
27
+
28
+ private
29
+
30
+ # Resolves group and feature from the route and configures the request.
31
+ # Used by GroupController (and everything inheriting it).
32
+ def setup_feature_pack_context
33
+ group_name, feature_name = feature_pack_route_segments
34
+ set_group(group_name)
35
+ @feature = @group.feature(feature_name) if feature_name
36
+
37
+ set_view_lookup_context_prefix
38
+ set_layout_paths
39
+ end
40
+
41
+ # Configures a request that must resolve to a feature (FeaturePack::Controller).
42
+ def setup_feature
43
+ set_group_and_feature
44
+ set_view_lookup_context_prefix
45
+ set_layout_paths
46
+ end
47
+
48
+ # @return [Array<Symbol>] [group_name, feature_name]; feature_name may be nil
49
+ def feature_pack_route_segments
50
+ path = (params[:controller] || controller_path).to_s
51
+ path.delete_prefix(ROUTE_NAMESPACE).split('/').first(2).map(&:to_sym)
52
+ end
53
+
54
+ def set_group(group_name)
55
+ @group = FeaturePack.group(group_name)
56
+ raise FeaturePack::Error::NoGroup, "Group '#{group_name}' not found" if @group.nil?
57
+ end
58
+
59
+ # Extracts and sets the group and feature from the controller path
60
+ def set_group_and_feature
61
+ group_name, feature_name = feature_pack_route_segments
62
+ set_group(group_name)
63
+
64
+ @feature = @group.feature(feature_name)
65
+ return unless @feature.nil?
66
+
67
+ raise FeaturePack::Error::NoDataError, "Feature '#{feature_name}' not found in group '#{group_name}'"
68
+ end
69
+
70
+ # Configures the view lookup path to include feature (or group) views.
71
+ # Overridable in group controllers; applies to group and feature requests.
72
+ def set_view_lookup_context_prefix
73
+ prepend_view_prefix(@feature ? @feature.views_relative_path : @group.views_path)
74
+ end
75
+
76
+ # Sets up header and footer layout paths with fallback logic
77
+ # Search order:
78
+ # 1. Feature-specific partials (feature requests only)
79
+ # 2. Group-level partials
80
+ # 3. Application default (if neither exists)
81
+ def set_layout_paths
82
+ @header_layout_path = resolve_layout_partial('header')
83
+ @footer_layout_path = resolve_layout_partial('footer')
84
+ end
85
+
86
+ def prepend_view_prefix(prefix)
87
+ prefix = prefix.to_s
88
+ return if _prefixes.include?(prefix)
89
+
90
+ @_feature_pack_prefixes = [prefix, *_prefixes].freeze
91
+ lookup_context.prefixes = @_feature_pack_prefixes if defined?(@_lookup_context) && @_lookup_context
92
+ end
93
+
94
+ # @return [String, nil] the view path of the first existing partial
95
+ def resolve_layout_partial(name)
96
+ layout_partial_candidates.each do |partials_prefix, view_path|
97
+ return format(view_path, name) if template_exists?(name, partials_prefix, true)
98
+ end
99
+ nil
100
+ end
101
+
102
+ # @return [Array<Array(String, String)>] pairs of [partials_prefix, view path format with %s for the name]
103
+ def layout_partial_candidates
104
+ candidates = []
105
+ if @feature
106
+ candidates << [@feature.views_relative_path.join('partials').to_s, @feature.view('partials/%s')]
107
+ end
108
+ candidates << ["#{@group.views_path}/partials", @group.view('partials/%s')]
109
+ candidates
110
+ end
111
+ end
@@ -1,45 +1,15 @@
1
- # Base controller for all group controllers
2
- # Handles the setup of groups and their views
1
+ require_relative 'feature_controller_setup'
2
+
3
+ # Base controller for all group controllers.
4
+ #
5
+ # Feature controllers inherit their group controller, so group callbacks
6
+ # (authentication, authorization...) apply to every feature. The setup callback
7
+ # resolves group or feature context from the route, so no extra declaration is
8
+ # needed in feature controllers.
3
9
  class FeaturePack::GroupController < ApplicationController
4
- prepend_before_action :setup_group
5
-
10
+ include FeaturePack::FeatureControllerSetup
11
+ prepend_before_action :setup_feature_pack_context
12
+
6
13
  # Default index action
7
14
  def index; end
8
-
9
- private
10
-
11
- # Main setup method that configures the group environment
12
- def setup_group
13
- set_group
14
- set_view_lookup_context_prefix
15
- set_layout_paths
16
- end
17
-
18
- # Extracts and sets the group from the controller path
19
- def set_group
20
- group_name = params[:controller].split('/')[1].to_sym
21
- @group = FeaturePack.group(group_name)
22
-
23
- raise FeaturePack::Error::NoGroup, "Group '#{group_name}' not found" if @group.nil?
24
- end
25
-
26
- # Configures the view lookup path to include group-specific views
27
- def set_view_lookup_context_prefix
28
- return if lookup_context.prefixes.include?(@group.views_path)
29
-
30
- lookup_context.prefixes.prepend(@group.views_path)
31
- end
32
-
33
- # Sets up header and footer layout paths for the group
34
- def set_layout_paths
35
- partials_path = @group.views_path.concat('/partials')
36
-
37
- if template_exists?('header', partials_path, true)
38
- @header_layout_path = @group.view('partials/header')
39
- end
40
-
41
- if template_exists?('footer', partials_path, true)
42
- @footer_layout_path = @group.view('partials/footer')
43
- end
44
- end
45
15
  end
data/lib/feature_pack.rb CHANGED
@@ -1,8 +1,14 @@
1
1
  require 'active_support/all'
2
+ require 'ostruct'
2
3
 
3
4
  # FeaturePack module provides a way to organize Rails applications into
4
5
  # groups and features, enabling better code organization and isolation
5
6
  module FeaturePack
7
+ @initialized = false
8
+
9
+ # Raised when registry data is requested before `setup` succeeded
10
+ class NotInitializedError < StandardError; end
11
+
6
12
  # Pattern constants for identifying groups and features
7
13
  GROUP_ID_PATTERN = /^group_.*?_/.freeze
8
14
  FEATURE_ID_PATTERN = /^feature_.*?_/.freeze
@@ -11,9 +17,10 @@ module FeaturePack
11
17
  GROUP_SPACE_DIRECTORY = '_group_space'.freeze
12
18
  MANIFEST_FILE_NAME = 'manifest.yaml'.freeze
13
19
  CONTROLLER_FILE_NAME = 'controller.rb'.freeze
14
- AFTER_INITIALIZE_FILE_NAME = '__after_initialize.rb'.freeze
20
+ # Hook file from 0.10.x; no longer loaded but still kept away from Zeitwerk
21
+ LEGACY_HOOK_FILE_NAME = '__after_initialize.rb'.freeze
15
22
 
16
- # Attribute readers that will be dynamically defined
23
+ # Registry attributes populated by `setup`
17
24
  ATTR_READERS = %i[
18
25
  path
19
26
  features_path
@@ -27,20 +34,39 @@ module FeaturePack
27
34
  class << self
28
35
  # Sets up the FeaturePack system
29
36
  # This method should be called once during Rails initialization
30
- def setup
31
- raise 'FeaturePack already setup!' if defined?(@@setup_executed_flag)
32
-
33
- initialize_paths
34
- load_dependencies
35
- discover_groups
36
- discover_features
37
- finalize_setup
37
+ # @param require_features_path [Boolean] when true (default), a missing
38
+ # app/feature_packs directory aborts the boot; pass false to boot with no
39
+ # groups (e.g. before generating the first group).
40
+ def setup(require_features_path: true)
41
+ raise 'FeaturePack already setup!' if @initialized
42
+
43
+ begin
44
+ initialize_paths(require_features_path)
45
+ load_dependencies
46
+ discover_groups
47
+ discover_features
48
+ finalize_setup
49
+ @initialized = true
50
+ rescue StandardError, ScriptError
51
+ # ScriptError covers LoadError/SyntaxError raised while loading files.
52
+ reset_state!
53
+ raise
54
+ end
55
+ end
56
+
57
+ def initialized? = @initialized
58
+
59
+ ATTR_READERS.each do |attr|
60
+ define_method(attr) do
61
+ ensure_initialized!
62
+ instance_variable_get("@#{attr}")
63
+ end
38
64
  end
39
65
 
40
66
  # Finds a group by name
41
67
  # @param group_name [Symbol] The name of the group
42
68
  # @return [OpenStruct, nil] The group object or nil if not found
43
- def group(group_name) = @@groups.find { it.name.eql?(group_name) }
69
+ def group(group_name) = groups.find { |group| group.name.eql?(group_name) }
44
70
 
45
71
  # Finds a feature within a group
46
72
  # @param group_name [Symbol] The name of the group
@@ -49,45 +75,68 @@ module FeaturePack
49
75
  def feature(group_name, feature_name)
50
76
  requested_group = group(group_name)
51
77
  return nil if requested_group.nil?
52
-
78
+
53
79
  requested_group.feature(feature_name)
54
80
  end
55
81
 
56
82
  private
57
83
 
58
- def initialize_paths
59
- @@path = Pathname.new(__dir__)
60
- @@features_path = Pathname.new(Rails.root.join('app/feature_packs'))
61
-
62
- validate_features_path!
63
-
64
- @@groups_controllers_paths = []
65
- @@features_controllers_paths = []
66
- @@ignored_paths = Dir.glob("#{@@features_path}/[!]*/")
67
- @@javascript_files_paths = discover_javascript_files
84
+ def ensure_initialized!
85
+ return if @initialized
86
+
87
+ raise NotInitializedError, 'FeaturePack is not set up. Call FeaturePack.setup first.'
88
+ end
89
+
90
+ def initialize_paths(require_features_path)
91
+ @path = Pathname.new(__dir__)
92
+ @features_path = Pathname.new(Rails.root.join('app/feature_packs'))
93
+
94
+ validate_features_path!(require_features_path)
95
+
96
+ @groups_controllers_paths = []
97
+ @features_controllers_paths = []
98
+ # Every entry under features_path is ignored by Zeitwerk; controllers and
99
+ # routes are loaded explicitly (see SETUP.md), and feature modules are
100
+ # registered through push_dir by the host application.
101
+ @ignored_paths = Dir.glob("#{@features_path}/*/")
102
+ @javascript_files_paths = discover_javascript_files
68
103
  end
69
104
 
70
- def load_dependencies = load @@path.join('feature_pack/error.rb')
105
+ def load_dependencies = load(@path.join('feature_pack/error.rb'))
106
+
107
+ def validate_features_path!(require_features_path)
108
+ if @features_path.exist?
109
+ raise "Features path is not a directory: '#{@features_path}'" unless @features_path.directory?
110
+ return
111
+ end
112
+
113
+ message = "Features path '#{@features_path}' does not exist"
114
+ if require_features_path
115
+ raise "#{message}. Create app/feature_packs or call FeaturePack.setup(require_features_path: false)"
116
+ end
117
+
118
+ log_warning("[FeaturePack] #{message}. No groups or features will be loaded.")
119
+ end
71
120
 
72
- def validate_features_path!
73
- raise "Invalid features_path: '#{@@features_path}'" if @@features_path.nil?
74
- raise "Features path does not exist: '#{@@features_path}'" unless Dir.exist?(@@features_path)
121
+ def log_warning(message)
122
+ logger = Rails.respond_to?(:logger) ? Rails.logger : nil
123
+ logger ? logger.warn(message) : warn(message)
75
124
  end
76
125
 
77
126
  def discover_javascript_files
78
- Dir.glob("#{@@features_path}/[!_]*/**/*.js")
79
- .map { |js_path| js_path.sub(/^#{Regexp.escape(@@features_path.to_s)}\//, '') }
80
- .to_a
127
+ prefix = "#{@features_path}/"
128
+ Dir.glob("#{prefix}[!_]*/**/*.js").map { |js_path| js_path.delete_prefix(prefix) }
81
129
  end
82
130
 
83
- def finalize_setup
84
- ATTR_READERS.each { |attr| define_singleton_method(attr) { class_variable_get("@@#{attr}") } }
85
- @@ignored_paths << @@path.join('feature_pack/feature_pack_routes.rb')
86
- execute_after_initialize_hooks
87
- @@setup_executed_flag = true
131
+ def finalize_setup = @ignored_paths << @path.join('feature_pack/feature_pack_routes.rb')
132
+
133
+ # Clears every registry attribute so a failed or repeated setup starts clean
134
+ def reset_state!
135
+ @initialized = false
136
+ ATTR_READERS.each { |attr| remove_instance_variable("@#{attr}") if instance_variable_defined?("@#{attr}") }
88
137
  end
89
138
 
90
- def discover_groups = @@groups = Dir.glob("#{@@features_path}/[!_]*/").map { build_group(it) }
139
+ def discover_groups = @groups = Dir.glob("#{@features_path}/[!_]*/").map { |group_path| build_group(group_path) }
91
140
 
92
141
  def build_group(group_path)
93
142
  relative_path = Pathname.new(group_path)
@@ -96,7 +145,7 @@ module FeaturePack
96
145
  validate_group_id!(base_path)
97
146
 
98
147
  routes_file = find_group_routes_file(group_path, base_path)
99
- @@groups_controllers_paths << File.join(group_path, GROUP_SPACE_DIRECTORY, CONTROLLER_FILE_NAME)
148
+ register_group_controller(group_path)
100
149
 
101
150
  group = create_group_struct(base_path, group_path, relative_path, routes_file)
102
151
  setup_group_aliases(group)
@@ -111,6 +160,14 @@ module FeaturePack
111
160
  end
112
161
  end
113
162
 
163
+ # Groups without a controller are allowed (namespace-only groups);
164
+ # add_feature validates the controller when a feature needs to inherit it.
165
+ def register_group_controller(group_path)
166
+ controller_path = File.join(group_path, GROUP_SPACE_DIRECTORY, CONTROLLER_FILE_NAME)
167
+ @groups_controllers_paths << controller_path if File.exist?(controller_path)
168
+ @ignored_paths << File.join(group_path, GROUP_SPACE_DIRECTORY, LEGACY_HOOK_FILE_NAME)
169
+ end
170
+
114
171
  def find_group_routes_file(group_path, base_path)
115
172
  routes_path = File.join(group_path, GROUP_SPACE_DIRECTORY, 'routes.rb')
116
173
  File.exist?(routes_path) ? File.join(base_path, GROUP_SPACE_DIRECTORY, 'routes') : nil
@@ -126,7 +183,7 @@ module FeaturePack
126
183
  OpenStruct.new(
127
184
  id: base_path.scan(GROUP_ID_PATTERN).first.delete_suffix('_'),
128
185
  name: base_path.gsub(GROUP_ID_PATTERN, '').to_sym,
129
- metadata_path: @@features_path.join(group_path, GROUP_SPACE_DIRECTORY),
186
+ metadata_path: @features_path.join(group_path, GROUP_SPACE_DIRECTORY),
130
187
  relative_path: relative_path,
131
188
  base_dir: File.basename(relative_path, File::SEPARATOR),
132
189
  routes_file: routes_file,
@@ -142,8 +199,8 @@ module FeaturePack
142
199
  end
143
200
 
144
201
  def setup_group_aliases(group)
145
- group.manifest.fetch(:const_aliases, []).each do
146
- alias_method_name, alias_const_name = it.first
202
+ group.manifest.fetch(:const_aliases, []).each do |const_alias|
203
+ alias_method_name, alias_const_name = const_alias.first
147
204
  group.define_singleton_method(alias_method_name) do
148
205
  "FeaturePack::#{group.name.to_s.camelize}::#{alias_const_name}".constantize
149
206
  end
@@ -151,14 +208,14 @@ module FeaturePack
151
208
  end
152
209
 
153
210
  def define_group_methods(group)
154
- def group.feature(feature_name) = features.find { it.name.eql?(feature_name) }
211
+ def group.feature(feature_name) = features.find { |feature| feature.name.eql?(feature_name) }
155
212
  def group.views_path = "#{base_dir}/#{GROUP_SPACE_DIRECTORY}/views"
156
213
  def group.view(view_name) = "#{base_dir}/#{GROUP_SPACE_DIRECTORY}/views/#{view_name}"
157
214
  def group.javascript_module(javascript_file_name) = "#{base_dir}/#{GROUP_SPACE_DIRECTORY}/javascript/#{javascript_file_name}"
158
215
  end
159
216
 
160
217
  def discover_features
161
- @@groups.each do |group|
218
+ @groups.each do |group|
162
219
  Dir.glob("#{group.relative_path}[!_]*/").each do |feature_path|
163
220
  build_feature(group, feature_path)
164
221
  end
@@ -166,7 +223,7 @@ module FeaturePack
166
223
  end
167
224
 
168
225
  def build_feature(group, feature_path)
169
- absolute_path = @@features_path.join(feature_path)
226
+ absolute_path = @features_path.join(feature_path)
170
227
  relative_path = Pathname.new(feature_path)
171
228
  base_path = File.basename(feature_path, File::SEPARATOR)
172
229
 
@@ -188,20 +245,6 @@ module FeaturePack
188
245
  group.features << feature
189
246
  end
190
247
 
191
- def execute_after_initialize_hooks
192
- # Executar hooks dos grupos
193
- @@groups.each do |group|
194
- hook_file = File.join(group.metadata_path, AFTER_INITIALIZE_FILE_NAME)
195
- group.instance_eval(File.read(hook_file), hook_file) if File.exist?(hook_file)
196
-
197
- # Executar hooks das features
198
- group.features.each do |feature|
199
- hook_file = File.join(feature.absolute_path, AFTER_INITIALIZE_FILE_NAME)
200
- feature.instance_eval(File.read(hook_file), hook_file) if File.exist?(hook_file)
201
- end
202
- end
203
- end
204
-
205
248
  def validate_feature_id!(base_path, relative_path)
206
249
  if base_path.scan(FEATURE_ID_PATTERN).empty?
207
250
  raise "Feature '#{relative_path}' does not have a valid ID. Expected format: feature_<id>_<name>"
@@ -209,20 +252,20 @@ module FeaturePack
209
252
  end
210
253
 
211
254
  def setup_feature_paths(relative_path, routes_file_path)
212
- # Handled after initialize hooks
213
- @@ignored_paths << File.join(relative_path, AFTER_INITIALIZE_FILE_NAME)
214
-
215
255
  # Custom routes file loads before Rails default routes
216
- @@ignored_paths << routes_file_path
256
+ @ignored_paths << routes_file_path
217
257
 
218
258
  # Controllers have special load process due to Zeitwerk
219
259
  controller_path = relative_path.join(CONTROLLER_FILE_NAME)
220
- @@features_controllers_paths << controller_path
221
- @@ignored_paths << controller_path
260
+ @features_controllers_paths << controller_path
261
+ @ignored_paths << controller_path
262
+ # Feature directories are Zeitwerk roots (push_dir), so the group-level
263
+ # ignore does not cover leftover 0.10.x hook files.
264
+ @ignored_paths << relative_path.join(LEGACY_HOOK_FILE_NAME)
222
265
  end
223
266
 
224
267
  def create_feature_struct(base_path, feature_name, group, absolute_path, relative_path, routes_file_path, feature_path)
225
- feature_sub_path = relative_path.sub(/^#{Regexp.escape(@@features_path.to_s)}\//, '')
268
+ feature_sub_path = relative_path.sub(/^#{Regexp.escape(@features_path.to_s)}\//, '')
226
269
  manifest_path = File.join(feature_path, MANIFEST_FILE_NAME)
227
270
 
228
271
  unless File.exist?(manifest_path)
@@ -239,8 +282,8 @@ module FeaturePack
239
282
  routes_file_path: routes_file_path,
240
283
  routes_file: feature_sub_path.join('routes'),
241
284
  views_absolute_path: absolute_path.join('views'),
242
- views_relative_path: relative_path.sub(/^#{Regexp.escape(@@features_path.to_s)}\//, '').join('views'),
243
- javascript_relative_path: relative_path.sub(/^#{Regexp.escape(@@features_path.to_s)}\//, '').join('javascript'),
285
+ views_relative_path: feature_sub_path.join('views'),
286
+ javascript_relative_path: feature_sub_path.join('javascript'),
244
287
  manifest: load_manifest(manifest_path)
245
288
  )
246
289
  end
@@ -253,8 +296,8 @@ module FeaturePack
253
296
  end
254
297
 
255
298
  def setup_feature_aliases(feature)
256
- feature.manifest.fetch(:const_aliases, []).each do
257
- alias_method_name, alias_const_name = it.first
299
+ feature.manifest.fetch(:const_aliases, []).each do |const_alias|
300
+ alias_method_name, alias_const_name = const_alias.first
258
301
  feature.define_singleton_method(alias_method_name) do
259
302
  "#{class_name}::#{alias_const_name}".constantize
260
303
  end
@@ -8,13 +8,15 @@ module FeaturePack
8
8
  desc 'Creates a new Feature within an existing Group'
9
9
  source_root File.expand_path('templates', __dir__)
10
10
 
11
- argument :name, type: :string, required: true, desc: 'The group/feature name (snake_case) format: group_name/feature_name'
11
+ argument :name, type: :string, required: true,
12
+ desc: 'The group/feature name (snake_case) format: group_name/feature_name'
12
13
 
13
14
  def add_feature
14
15
  validate_feature_name!
15
16
  parse_names
16
17
  check_group_existence!
17
18
  check_feature_existence!
19
+ check_group_controller!
18
20
 
19
21
  @feature_id = generate_feature_id
20
22
  @feature_dir = @group.relative_path.join("feature_#{@feature_id}_#{@feature_name}")
@@ -36,8 +38,8 @@ module FeaturePack
36
38
  def parse_names
37
39
  @group_name, @feature_name = name.split('/')
38
40
 
39
- unless @group_name.match?(/^[a-z][a-z0-9_]*$/) && @feature_name.match?(/^[a-z][a-z0-9_]*$/)
40
- raise Thor::Error, "Group and feature names must be in snake_case format (lowercase letters, numbers, and underscores, starting with a letter)"
41
+ unless @group_name.match?(/^[a-z_]+$/) && @feature_name.match?(/^[a-z_]+$/)
42
+ raise Thor::Error, "Group and feature names must be in snake_case format"
41
43
  end
42
44
 
43
45
  @group_class_name = @group_name.camelcase
@@ -52,6 +54,31 @@ module FeaturePack
52
54
  end
53
55
  end
54
56
 
57
+ # The generated feature controller inherits FeaturePack::<Group>Controller,
58
+ # so validate the resolved class independently of its declaration syntax.
59
+ def check_group_controller!
60
+ controller_path = @group.metadata_path.join(FeaturePack::CONTROLLER_FILE_NAME)
61
+ expected_class = "FeaturePack::#{@group_class_name}Controller"
62
+
63
+ unless File.exist?(controller_path)
64
+ raise Thor::Error,
65
+ "Group controller not found at #{controller_path}. The feature controller must inherit #{expected_class}."
66
+ end
67
+
68
+ controller_class = expected_class.safe_constantize || load_group_controller(controller_path, expected_class)
69
+
70
+ return if controller_class.is_a?(Class)
71
+
72
+ raise Thor::Error, "#{controller_path} does not define #{expected_class}, which the feature controller inherits."
73
+ end
74
+
75
+ def load_group_controller(controller_path, expected_class)
76
+ load controller_path
77
+ expected_class.safe_constantize
78
+ rescue StandardError, ScriptError => e
79
+ raise Thor::Error, "Could not load group controller #{controller_path}: #{e.class}: #{e.message}"
80
+ end
81
+
55
82
  def check_feature_existence!
56
83
  if FeaturePack.feature(@group_name.to_sym, @feature_name.to_sym).present?
57
84
  raise Thor::Error, "Feature '#{@feature_name}' already exists in group '#{@group_name}'"
@@ -1,5 +1,7 @@
1
1
  # Feature controller for <%= @feature_class_name %>
2
- class FeaturePack::<%= @group_class_name %>::<%= @feature_class_name %>Controller < FeaturePack::Controller
2
+ class FeaturePack::<%= @group_class_name %>::<%= @feature_class_name %>Controller < FeaturePack::<%= @group_class_name %>Controller
3
+ # Inherits the group controller and its callbacks. The request receives
4
+ # @group and @feature automatically based on the route.
3
5
  # The 'index' action is already defined in the parent controller
4
6
  # Add your feature-specific actions and logic here
5
7
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feature_pack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gedean Dias
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-10-06 00:00:00.000000000 Z
10
+ date: 2026-09-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -29,6 +29,20 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: ostruct
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
32
46
  description: |
33
47
  Organizes and sets up the architecture of micro-applications within a Rails application,
34
48
  enabling segregation, management, and isolation of functionalities, thereby supporting
@@ -40,16 +54,14 @@ extra_rdoc_files: []
40
54
  files:
41
55
  - README.md
42
56
  - doc/feature_pack.md
43
- - doc/hooks.md
44
57
  - lib/feature_pack.rb
45
- - lib/feature_pack/api/controller.rb
46
58
  - lib/feature_pack/controller.rb
47
59
  - lib/feature_pack/error.rb
60
+ - lib/feature_pack/feature_controller_setup.rb
48
61
  - lib/feature_pack/feature_pack_routes.rb
49
62
  - lib/feature_pack/group_controller.rb
50
63
  - lib/generators/feature_pack/add_feature/USAGE
51
64
  - lib/generators/feature_pack/add_feature/add_feature_generator.rb
52
- - lib/generators/feature_pack/add_feature/templates/after_initialize.rb.tt
53
65
  - lib/generators/feature_pack/add_feature/templates/controller.rb.tt
54
66
  - lib/generators/feature_pack/add_feature/templates/doc/readme.md.tt
55
67
  - lib/generators/feature_pack/add_feature/templates/manifest.yaml.tt
@@ -59,7 +71,6 @@ files:
59
71
  - lib/generators/feature_pack/add_feature/templates/views/partials/_header.html.slim.tt
60
72
  - lib/generators/feature_pack/add_group/USAGE
61
73
  - lib/generators/feature_pack/add_group/add_group_generator.rb
62
- - lib/generators/feature_pack/add_group/templates/_group_space/after_initialize.rb.tt
63
74
  - lib/generators/feature_pack/add_group/templates/_group_space/controller.rb.tt
64
75
  - lib/generators/feature_pack/add_group/templates/_group_space/manifest.yaml.tt
65
76
  - lib/generators/feature_pack/add_group/templates/_group_space/routes.rb.tt
@@ -88,7 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
99
  - !ruby/object:Gem::Version
89
100
  version: '0'
90
101
  requirements: []
91
- rubygems_version: 3.7.2
102
+ rubygems_version: 4.0.20
92
103
  specification_version: 4
93
104
  summary: A different approach to organizing Rails app features.
94
105
  test_files: []
data/doc/hooks.md DELETED
@@ -1,108 +0,0 @@
1
- # Sistema de Hooks do FeaturePack
2
-
3
- ## Hook after_initialize
4
-
5
- O FeaturePack agora suporta hooks `after_initialize` que permitem executar código customizado após o carregamento de grupos e features.
6
-
7
- ### Como funciona
8
-
9
- Durante o processo de setup do FeaturePack, após todos os grupos e features serem descobertos e configurados, o sistema procura e executa arquivos `after_initialize.rb` específicos.
10
-
11
- ### Localização dos arquivos
12
-
13
- - **Para grupos**: `app/feature_packs/[nome_do_grupo]/_group_space/after_initialize.rb`
14
- - **Para features**: `app/feature_packs/[nome_do_grupo]/[nome_da_feature]/after_initialize.rb`
15
-
16
- ### Contexto de execução
17
-
18
- Os arquivos `after_initialize.rb` são executados no contexto do objeto group ou feature, permitindo acesso direto a todas as suas propriedades através de `self`.
19
-
20
- ### Exemplos de uso
21
-
22
- #### Hook para grupo
23
-
24
- ```ruby
25
- # app/feature_packs/group_admin/_group_space/after_initialize.rb
26
-
27
- # Registrar o grupo em um sistema de auditoria
28
- Rails.logger.info "Grupo #{name} carregado com #{features.size} features"
29
-
30
- # Configurar permissões globais do grupo
31
- features.each do |feature|
32
- Rails.logger.info " - Feature #{feature.name} disponível em #{feature.manifest[:url]}"
33
- end
34
-
35
- # Carregar configurações específicas do grupo
36
- config_file = File.join(absolute_path, GROUP_SPACE_DIRECTORY, 'config.yml')
37
- if File.exist?(config_file)
38
- @config = YAML.load_file(config_file)
39
- end
40
- ```
41
-
42
- #### Hook para feature
43
-
44
- ```ruby
45
- # app/feature_packs/group_admin/feature_users/after_initialize.rb
46
-
47
- # Registrar rotas dinâmicas
48
- Rails.logger.info "Feature #{name} inicializada no grupo #{group.name}"
49
-
50
- # Verificar dependências
51
- required_gems = %w[devise cancancan]
52
- required_gems.each do |gem_name|
53
- unless Gem.loaded_specs.key?(gem_name)
54
- Rails.logger.warn "Feature #{name} requer a gem #{gem_name}"
55
- end
56
- end
57
-
58
- # Registrar a feature em um sistema de métricas
59
- StatsD.increment("features.#{group.name}.#{name}.loaded")
60
-
61
- # Configurar cache específico da feature
62
- Rails.cache.write("feature:#{group.name}:#{name}:loaded_at", Time.current)
63
- ```
64
-
65
- ### Propriedades disponíveis
66
-
67
- #### No contexto de grupo
68
-
69
- - `name` - Nome do grupo (symbol)
70
- - `absolute_path` - Caminho absoluto do grupo
71
- - `relative_path` - Caminho relativo do grupo
72
- - `features` - Array com todas as features do grupo
73
- - `manifest` - Hash com dados do manifest.yaml
74
- - `routes_file` - Caminho do arquivo de rotas
75
-
76
- #### No contexto de feature
77
-
78
- - `name` - Nome da feature (symbol)
79
- - `group` - Referência ao grupo pai
80
- - `absolute_path` - Caminho absoluto da feature
81
- - `relative_path` - Caminho relativo da feature
82
- - `namespace` - Módulo Ruby da feature
83
- - `manifest` - Hash com dados do manifest.yaml
84
- - `routes_file` - Caminho do arquivo de rotas
85
-
86
- ### Casos de uso comuns
87
-
88
- 1. **Logging e auditoria** - Registrar quando grupos/features são carregados
89
- 2. **Validação de dependências** - Verificar se gems ou recursos necessários estão disponíveis
90
- 3. **Configuração dinâmica** - Carregar configurações específicas
91
- 4. **Registro em sistemas externos** - Integrar com sistemas de métricas ou monitoramento
92
- 5. **Inicialização de recursos** - Preparar caches, conexões ou outros recursos
93
- 6. **Verificação de segurança** - Validar permissões ou políticas de acesso
94
-
95
- ### Boas práticas
96
-
97
- 1. Mantenha os hooks leves e rápidos - eles são executados durante o boot da aplicação
98
- 2. Use logging apropriado para facilitar debug
99
- 3. Trate exceções adequadamente para não quebrar o processo de inicialização
100
- 4. Evite operações síncronas pesadas (I/O, rede, etc)
101
- 5. Use o hook apenas para configurações que realmente precisam acontecer após a carga completa
102
-
103
- ### Ordem de execução
104
-
105
- 1. Todos os grupos são descobertos e configurados
106
- 2. Todas as features são descobertas e configuradas
107
- 3. Hooks `after_initialize` dos grupos são executados
108
- 4. Hooks `after_initialize` das features são executados (na ordem de descoberta)
@@ -1,3 +0,0 @@
1
- class FeaturePack::API::Controller < ActionController::API
2
-
3
- end
@@ -1,11 +0,0 @@
1
- # Hook executado após a inicialização da feature <%= @feature_class_name %>
2
- # Este arquivo é executado no contexto do objeto feature
3
- # Você tem acesso a todas as propriedades da feature através de 'self'
4
- #
5
- # Exemplo de uso:
6
- # puts "Feature #{name} foi inicializada!"
7
- # puts "Grupo da feature: #{group.name}"
8
- # puts "Caminho absoluto: #{absolute_path}"
9
- # puts "Namespace: #{namespace}"
10
- #
11
- # Adicione seu código customizado abaixo:
@@ -1,10 +0,0 @@
1
- # Hook executado após a inicialização do grupo <%= @class_name %>
2
- # Este arquivo é executado no contexto do objeto group
3
- # Você tem acesso a todas as propriedades do grupo através de 'self'
4
- #
5
- # Exemplo de uso:
6
- # puts "Grupo #{name} foi inicializado!"
7
- # puts "Caminho do grupo: #{absolute_path}"
8
- # puts "Features do grupo: #{features.map(&:name).join(', ')}"
9
- #
10
- # Adicione seu código customizado abaixo: