better_service 1.0.0 → 1.1.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +13 -0
  3. data/README.md +256 -18
  4. data/Rakefile +202 -2
  5. data/config/locales/better_service.en.yml +37 -0
  6. data/lib/better_service/concerns/serviceable/messageable.rb +45 -2
  7. data/lib/better_service/concerns/serviceable/validatable.rb +0 -5
  8. data/lib/better_service/concerns/serviceable/viewable.rb +0 -16
  9. data/lib/better_service/presenter.rb +131 -0
  10. data/lib/better_service/railtie.rb +17 -0
  11. data/lib/better_service/services/base.rb +78 -21
  12. data/lib/better_service/services/create_service.rb +3 -0
  13. data/lib/better_service/services/destroy_service.rb +3 -0
  14. data/lib/better_service/services/update_service.rb +3 -0
  15. data/lib/better_service/subscribers/log_subscriber.rb +25 -5
  16. data/lib/better_service/version.rb +1 -1
  17. data/lib/better_service/workflows/base.rb +1 -0
  18. data/lib/better_service/workflows/branch.rb +133 -0
  19. data/lib/better_service/workflows/branch_dsl.rb +151 -0
  20. data/lib/better_service/workflows/branch_group.rb +139 -0
  21. data/lib/better_service/workflows/dsl.rb +46 -0
  22. data/lib/better_service/workflows/execution.rb +35 -9
  23. data/lib/better_service/workflows/result_builder.rb +26 -17
  24. data/lib/better_service.rb +4 -0
  25. data/lib/generators/better_service/install_generator.rb +38 -0
  26. data/lib/generators/better_service/locale_generator.rb +54 -0
  27. data/lib/generators/better_service/presenter_generator.rb +60 -0
  28. data/lib/generators/better_service/templates/better_service_initializer.rb.tt +90 -0
  29. data/lib/generators/better_service/templates/locale.en.yml.tt +27 -0
  30. data/lib/generators/better_service/templates/presenter.rb.tt +53 -0
  31. data/lib/generators/better_service/templates/presenter_test.rb.tt +46 -0
  32. data/lib/generators/serviceable/scaffold_generator.rb +9 -0
  33. data/lib/generators/serviceable/templates/create_service.rb.tt +11 -1
  34. data/lib/generators/serviceable/templates/destroy_service.rb.tt +11 -1
  35. data/lib/generators/serviceable/templates/index_service.rb.tt +19 -1
  36. data/lib/generators/serviceable/templates/show_service.rb.tt +19 -1
  37. data/lib/generators/serviceable/templates/update_service.rb.tt +11 -1
  38. data/lib/generators/workflowable/templates/workflow.rb.tt +22 -0
  39. metadata +16 -4
  40. data/MIT-LICENSE +0 -20
@@ -10,7 +10,6 @@ module BetterService
10
10
 
11
11
  included do
12
12
  class_attribute :_schema, default: nil
13
- attr_reader :validation_errors
14
13
  end
15
14
 
16
15
  class_methods do
@@ -23,10 +22,6 @@ module BetterService
23
22
  end
24
23
  end
25
24
 
26
- def valid?
27
- @validation_errors.empty?
28
- end
29
-
30
25
  private
31
26
 
32
27
  def validate_params!
@@ -20,22 +20,6 @@ module BetterService
20
20
 
21
21
  private
22
22
 
23
- # Override respond to add viewer phase
24
- def respond(data)
25
- # Get base result from parent respond
26
- if self.class._respond_block
27
- result = instance_exec(data, &self.class._respond_block)
28
- else
29
- result = success_result("Operation completed successfully", data)
30
- end
31
-
32
- # Add viewer if enabled
33
- return result unless viewer_enabled?
34
-
35
- view_config = execute_viewer(data, data, result)
36
- result.merge(view: view_config)
37
- end
38
-
39
23
  def viewer_enabled?
40
24
  self.class._viewer_enabled && self.class._viewer_block.present?
41
25
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ # Presenter - Base class for presenting service data
5
+ #
6
+ # Presenters transform raw model data into view-friendly formats.
7
+ # They are typically used with the Presentable concern's presenter DSL.
8
+ #
9
+ # Example:
10
+ # class ProductPresenter < BetterService::Presenter
11
+ # def as_json(opts = {})
12
+ # {
13
+ # id: object.id,
14
+ # name: object.name,
15
+ # price_formatted: "$#{object.price}",
16
+ # available: object.stock > 0,
17
+ # # Conditional fields based on current user
18
+ # **(admin_fields if current_user&.admin?)
19
+ # }
20
+ # end
21
+ #
22
+ # private
23
+ #
24
+ # def admin_fields
25
+ # {
26
+ # cost: object.cost,
27
+ # margin: object.price - object.cost
28
+ # }
29
+ # end
30
+ # end
31
+ #
32
+ # Usage with services:
33
+ # class Products::IndexService < IndexService
34
+ # presenter ProductPresenter
35
+ #
36
+ # presenter_options do
37
+ # { current_user: user }
38
+ # end
39
+ #
40
+ # search_with do
41
+ # { items: Product.all.to_a }
42
+ # end
43
+ # end
44
+ class Presenter
45
+ attr_reader :object, :options
46
+
47
+ # Initialize presenter
48
+ #
49
+ # @param object [Object] The object to present (e.g., ActiveRecord model)
50
+ # @param options [Hash] Additional options (e.g., current_user, permissions)
51
+ def initialize(object, **options)
52
+ @object = object
53
+ @options = options
54
+ end
55
+
56
+ # Override in subclass to define JSON representation
57
+ #
58
+ # @param opts [Hash] JSON serialization options
59
+ # @return [Hash] Hash representation of the object
60
+ def as_json(opts = {})
61
+ # Default implementation delegates to object
62
+ if object.respond_to?(:as_json)
63
+ object.as_json(opts)
64
+ else
65
+ { data: object }
66
+ end
67
+ end
68
+
69
+ # JSON string representation
70
+ #
71
+ # @param opts [Hash] JSON serialization options
72
+ # @return [String] JSON string
73
+ def to_json(opts = {})
74
+ as_json(opts).to_json
75
+ end
76
+
77
+ # Hash representation (alias for as_json)
78
+ #
79
+ # @return [Hash] Hash representation
80
+ def to_h
81
+ as_json
82
+ end
83
+
84
+ private
85
+
86
+ # Get current user from options
87
+ #
88
+ # @return [Object, nil] Current user if provided in options
89
+ def current_user
90
+ options[:current_user]
91
+ end
92
+
93
+ # Check if a field should be included based on options
94
+ #
95
+ # Useful for selective field rendering based on client requests.
96
+ #
97
+ # @param field [Symbol, String] Field name to check
98
+ # @return [Boolean] Whether field should be included
99
+ #
100
+ # @example
101
+ # # In service:
102
+ # presenter_options do
103
+ # { fields: params[:fields]&.split(',')&.map(&:to_sym) }
104
+ # end
105
+ #
106
+ # # In presenter:
107
+ # def as_json(opts = {})
108
+ # {
109
+ # id: object.id,
110
+ # name: object.name,
111
+ # **(expensive_data if include_field?(:details))
112
+ # }
113
+ # end
114
+ def include_field?(field)
115
+ return true unless options[:fields]
116
+
117
+ options[:fields].include?(field.to_sym)
118
+ end
119
+
120
+ # Check if current user has a specific role/permission
121
+ #
122
+ # @param role [Symbol, String] Role to check
123
+ # @return [Boolean] Whether user has the role
124
+ def user_can?(role)
125
+ return false unless current_user
126
+ return false unless current_user.respond_to?(:has_role?)
127
+
128
+ current_user.has_role?(role)
129
+ end
130
+ end
131
+ end
@@ -1,6 +1,23 @@
1
1
  if defined?(Rails)
2
2
  module BetterService
3
3
  class Railtie < ::Rails::Railtie
4
+ # Initialize subscribers after Rails boots
5
+ #
6
+ # This hook runs after all initializers have been executed,
7
+ # ensuring BetterService.configuration is fully loaded.
8
+ config.after_initialize do
9
+ # Attach LogSubscriber if enabled in configuration
10
+ if BetterService.configuration.log_subscriber_enabled
11
+ BetterService::Subscribers::LogSubscriber.attach
12
+ Rails.logger.info "[BetterService] LogSubscriber attached" if Rails.logger
13
+ end
14
+
15
+ # Attach StatsSubscriber if enabled in configuration
16
+ if BetterService.configuration.stats_subscriber_enabled
17
+ BetterService::Subscribers::StatsSubscriber.attach
18
+ Rails.logger.info "[BetterService] StatsSubscriber attached" if Rails.logger
19
+ end
20
+ end
4
21
  end
5
22
  end
6
23
  end
@@ -19,6 +19,7 @@ module BetterService
19
19
  class_attribute :_process_block, default: nil
20
20
  class_attribute :_transform_block, default: nil
21
21
  class_attribute :_respond_block, default: nil
22
+ class_attribute :_auto_invalidate_cache, default: false
22
23
 
23
24
  include Concerns::Serviceable::Messageable
24
25
  include Concerns::Serviceable::Validatable
@@ -41,11 +42,29 @@ module BetterService
41
42
  validate_schema_presence!
42
43
  @user = user
43
44
  @params = safe_params_to_hash(params)
44
- @validation_errors = {}
45
45
  validate_params!
46
46
  end
47
47
 
48
48
  # DSL methods to define phase blocks
49
+
50
+ # Configure whether this service allows nil user
51
+ #
52
+ # @param value [Boolean] Whether to allow nil user (default: true)
53
+ # @return [void]
54
+ #
55
+ # @example Allow nil user
56
+ # class PublicService < BetterService::Services::Base
57
+ # allow_nil_user true
58
+ # end
59
+ #
60
+ # @example Require user (default)
61
+ # class PrivateService < BetterService::Services::Base
62
+ # allow_nil_user false
63
+ # end
64
+ def self.allow_nil_user(value = true)
65
+ self._allow_nil_user = value
66
+ end
67
+
49
68
  def self.search_with(&block)
50
69
  self._search_block = block
51
70
  end
@@ -62,6 +81,31 @@ module BetterService
62
81
  self._respond_block = block
63
82
  end
64
83
 
84
+ # Configure automatic cache invalidation after write operations
85
+ #
86
+ # @param enabled [Boolean] Whether to automatically invalidate cache (default: true)
87
+ # @return [void]
88
+ #
89
+ # @example Enable automatic cache invalidation (default for Create/Update/Destroy)
90
+ # class Products::CreateService < CreateService
91
+ # cache_contexts :products, :category_products
92
+ # # Cache is automatically invalidated after successful create
93
+ # end
94
+ #
95
+ # @example Disable automatic cache invalidation
96
+ # class Products::CreateService < CreateService
97
+ # auto_invalidate_cache false
98
+ #
99
+ # process_with do |data|
100
+ # product = Product.create!(params)
101
+ # invalidate_cache_for(user) if should_invalidate? # Manual control
102
+ # { resource: product }
103
+ # end
104
+ # end
105
+ def self.auto_invalidate_cache(enabled = true)
106
+ self._auto_invalidate_cache = enabled
107
+ end
108
+
65
109
  # Main entry point - executes the 5-phase flow
66
110
  def call
67
111
  # Validation already raises ValidationError in initialize
@@ -70,6 +114,12 @@ module BetterService
70
114
 
71
115
  data = search
72
116
  processed = process(data)
117
+
118
+ # Auto-invalidate cache after write operations if configured
119
+ if should_auto_invalidate_cache?
120
+ invalidate_cache_for(user)
121
+ end
122
+
73
123
  transformed = transform(processed)
74
124
  result = respond(transformed)
75
125
 
@@ -117,7 +167,33 @@ module BetterService
117
167
 
118
168
  # Phase 3: Transform - Handled by Presentable concern
119
169
 
120
- # Phase 4: Respond - Handled by Viewable concern
170
+ # Phase 4: Respond - Format response (override in service types or with respond_with block)
171
+ def respond(data)
172
+ if self.class._respond_block
173
+ instance_exec(data, &self.class._respond_block)
174
+ else
175
+ success_result("Operation completed successfully", data)
176
+ end
177
+ end
178
+
179
+ # Check if cache should be automatically invalidated
180
+ #
181
+ # Auto-invalidation happens when:
182
+ # 1. auto_invalidate_cache is enabled (true)
183
+ # 2. cache_contexts are defined (something to invalidate)
184
+ # 3. Service is a write operation (Create/Update/Destroy)
185
+ #
186
+ # @return [Boolean] Whether cache should be invalidated
187
+ def should_auto_invalidate_cache?
188
+ return false unless self.class._auto_invalidate_cache
189
+ return false unless self.class.respond_to?(:_cache_contexts)
190
+ return false unless self.class._cache_contexts.present?
191
+
192
+ # Only auto-invalidate for write operations
193
+ is_a?(Services::CreateService) ||
194
+ is_a?(Services::UpdateService) ||
195
+ is_a?(Services::DestroyService)
196
+ end
121
197
 
122
198
  # Error handlers for runtime errors
123
199
  #
@@ -222,25 +298,6 @@ module BetterService
222
298
  }
223
299
  end
224
300
 
225
- def failure_result(message, errors = {}, code: nil)
226
- result = {
227
- success: false,
228
- error: message,
229
- errors: errors
230
- }
231
- result[:code] = code if code
232
- result
233
- end
234
-
235
- def error_result(message, code: nil)
236
- result = {
237
- success: false,
238
- errors: [message]
239
- }
240
- result[:code] = code if code
241
- result
242
- end
243
-
244
301
  # Prepend Instrumentation at the end, after call method is defined
245
302
  # This wraps the entire call method (including cache logic from Cacheable)
246
303
  prepend Concerns::Instrumentation
@@ -34,6 +34,9 @@ module BetterService
34
34
  # Enable database transactions by default for create operations
35
35
  with_transaction true
36
36
 
37
+ # Enable automatic cache invalidation for create operations
38
+ self._auto_invalidate_cache = true
39
+
37
40
  # Default empty schema - subclasses MUST override with specific validations
38
41
  schema do
39
42
  # Override in subclass with required fields
@@ -31,6 +31,9 @@ module BetterService
31
31
  # Enable database transactions by default for destroy operations
32
32
  with_transaction true
33
33
 
34
+ # Enable automatic cache invalidation for destroy operations
35
+ self._auto_invalidate_cache = true
36
+
34
37
  # Default schema - requires id parameter
35
38
  schema do
36
39
  required(:id).filled
@@ -32,6 +32,9 @@ module BetterService
32
32
  # Enable database transactions by default for update operations
33
33
  with_transaction true
34
34
 
35
+ # Enable automatic cache invalidation for update operations
36
+ self._auto_invalidate_cache = true
37
+
35
38
  # Default schema - requires id parameter
36
39
  schema do
37
40
  required(:id).filled
@@ -14,6 +14,11 @@ module BetterService
14
14
  # end
15
15
  class LogSubscriber
16
16
  class << self
17
+ # Storage for ActiveSupport::Notifications subscriptions
18
+ #
19
+ # @return [Array<ActiveSupport::Notifications::Fanout::Subscriber>]
20
+ attr_reader :subscriptions
21
+
17
22
  # Attach the subscriber to ActiveSupport::Notifications
18
23
  #
19
24
  # This method is called automatically when subscriber is enabled.
@@ -21,25 +26,40 @@ module BetterService
21
26
  #
22
27
  # @return [void]
23
28
  def attach
29
+ @subscriptions ||= []
24
30
  subscribe_to_service_events
25
31
  subscribe_to_cache_events
26
32
  end
27
33
 
34
+ # Detach the subscriber from ActiveSupport::Notifications
35
+ #
36
+ # Removes all subscriptions. Useful for testing.
37
+ #
38
+ # @return [void]
39
+ def detach
40
+ if @subscriptions
41
+ @subscriptions.each do |subscription|
42
+ ActiveSupport::Notifications.unsubscribe(subscription)
43
+ end
44
+ end
45
+ @subscriptions = []
46
+ end
47
+
28
48
  private
29
49
 
30
50
  # Subscribe to service lifecycle events
31
51
  #
32
52
  # @return [void]
33
53
  def subscribe_to_service_events
34
- ActiveSupport::Notifications.subscribe("service.started") do |name, start, finish, id, payload|
54
+ @subscriptions << ActiveSupport::Notifications.subscribe("service.started") do |name, start, finish, id, payload|
35
55
  log_service_started(payload)
36
56
  end
37
57
 
38
- ActiveSupport::Notifications.subscribe("service.completed") do |name, start, finish, id, payload|
58
+ @subscriptions << ActiveSupport::Notifications.subscribe("service.completed") do |name, start, finish, id, payload|
39
59
  log_service_completed(payload)
40
60
  end
41
61
 
42
- ActiveSupport::Notifications.subscribe("service.failed") do |name, start, finish, id, payload|
62
+ @subscriptions << ActiveSupport::Notifications.subscribe("service.failed") do |name, start, finish, id, payload|
43
63
  log_service_failed(payload)
44
64
  end
45
65
  end
@@ -48,11 +68,11 @@ module BetterService
48
68
  #
49
69
  # @return [void]
50
70
  def subscribe_to_cache_events
51
- ActiveSupport::Notifications.subscribe("cache.hit") do |name, start, finish, id, payload|
71
+ @subscriptions << ActiveSupport::Notifications.subscribe("cache.hit") do |name, start, finish, id, payload|
52
72
  log_cache_hit(payload)
53
73
  end
54
74
 
55
- ActiveSupport::Notifications.subscribe("cache.miss") do |name, start, finish, id, payload|
75
+ @subscriptions << ActiveSupport::Notifications.subscribe("cache.miss") do |name, start, finish, id, payload|
56
76
  log_cache_miss(payload)
57
77
  end
58
78
  end
@@ -1,3 +1,3 @@
1
1
  module BetterService
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -66,6 +66,7 @@ module BetterService
66
66
  @params = params
67
67
  @context = Workflowable::Context.new(user, **params)
68
68
  @executed_steps = []
69
+ @branch_decisions = []
69
70
  @start_time = nil
70
71
  @end_time = nil
71
72
  end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ module Workflows
5
+ # Represents a single conditional branch within a workflow
6
+ #
7
+ # A Branch contains:
8
+ # - A condition (Proc) that determines if the branch should execute
9
+ # - An array of steps to execute if the condition is true
10
+ # - An optional name for identification
11
+ #
12
+ # @example
13
+ # branch = Branch.new(
14
+ # condition: ->(ctx) { ctx.user.premium? },
15
+ # name: :premium_path
16
+ # )
17
+ # branch.add_step(step1)
18
+ # branch.add_step(step2)
19
+ #
20
+ # if branch.matches?(context)
21
+ # branch.execute(context, user, params)
22
+ # end
23
+ class Branch
24
+ attr_reader :condition, :steps, :name
25
+
26
+ # Creates a new Branch
27
+ #
28
+ # @param condition [Proc, nil] The condition to evaluate (nil for default/otherwise branch)
29
+ # @param name [Symbol, nil] Optional name for the branch
30
+ def initialize(condition: nil, name: nil)
31
+ @condition = condition
32
+ @steps = []
33
+ @name = name
34
+ end
35
+
36
+ # Checks if this branch's condition matches the given context
37
+ #
38
+ # @param context [Workflowable::Context] The workflow context
39
+ # @return [Boolean] true if condition matches or is nil (default branch)
40
+ def matches?(context)
41
+ return true if condition.nil? # Default branch always matches
42
+
43
+ if condition.is_a?(Proc)
44
+ context.instance_exec(context, &condition)
45
+ else
46
+ condition.call(context)
47
+ end
48
+ rescue StandardError => e
49
+ Rails.logger.error "Branch condition evaluation failed: #{e.message}"
50
+ false
51
+ end
52
+
53
+ # Adds a step to this branch
54
+ #
55
+ # @param step [Workflowable::Step] The step to add
56
+ # @return [Array] The updated steps array
57
+ def add_step(step)
58
+ @steps << step
59
+ end
60
+
61
+ # Executes all steps in this branch
62
+ #
63
+ # @param context [Workflowable::Context] The workflow context
64
+ # @param user [Object] The current user
65
+ # @param params [Hash] The workflow parameters
66
+ # @param branch_decisions [Array, nil] Array to track branch decisions for nested branches
67
+ # @return [Array<Workflowable::Step>] Array of successfully executed steps
68
+ # @raise [Errors::Workflowable::Runtime::StepExecutionError] If a required step fails
69
+ def execute(context, user, params, branch_decisions = nil)
70
+ executed_steps = []
71
+
72
+ @steps.each do |step_or_branch_group|
73
+ # Handle nested BranchGroup
74
+ if step_or_branch_group.is_a?(BranchGroup)
75
+ branch_result = step_or_branch_group.call(context, user, params)
76
+
77
+ # Add executed steps from nested branch
78
+ if branch_result[:executed_steps]
79
+ executed_steps.concat(branch_result[:executed_steps])
80
+ end
81
+
82
+ # Track nested branch decisions
83
+ if branch_decisions && branch_result[:branch_decisions]
84
+ branch_decisions.concat(branch_result[:branch_decisions])
85
+ end
86
+
87
+ next
88
+ end
89
+
90
+ # Handle regular Step
91
+ result = step_or_branch_group.call(context, user, params)
92
+
93
+ # Skip if step was skipped
94
+ next if result[:skipped]
95
+
96
+ # Handle step failure
97
+ if result[:success] == false && !result[:optional_failure]
98
+ raise Errors::Workflowable::Runtime::StepExecutionError.new(
99
+ "Step #{step_or_branch_group.name} failed in branch",
100
+ code: ErrorCodes::STEP_EXECUTION_FAILED,
101
+ context: {
102
+ step: step_or_branch_group.name,
103
+ branch: @name,
104
+ errors: result[:errors]
105
+ }
106
+ )
107
+ end
108
+
109
+ # Track successfully executed steps
110
+ executed_steps << step_or_branch_group unless result[:optional_failure]
111
+ end
112
+
113
+ executed_steps
114
+ end
115
+
116
+ # Returns whether this is a default branch (no condition)
117
+ #
118
+ # @return [Boolean]
119
+ def default?
120
+ condition.nil?
121
+ end
122
+
123
+ # Returns a string representation of this branch
124
+ #
125
+ # @return [String]
126
+ def inspect
127
+ "#<BetterService::Workflows::Branch name=#{@name.inspect} " \
128
+ "condition=#{@condition.present? ? 'present' : 'nil'} " \
129
+ "steps=#{@steps.count}>"
130
+ end
131
+ end
132
+ end
133
+ end