better_service 1.0.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1321 -0
- data/Rakefile +15 -0
- data/lib/better_service/cache_service.rb +310 -0
- data/lib/better_service/concerns/instrumentation.rb +242 -0
- data/lib/better_service/concerns/serviceable/authorizable.rb +106 -0
- data/lib/better_service/concerns/serviceable/cacheable.rb +97 -0
- data/lib/better_service/concerns/serviceable/messageable.rb +30 -0
- data/lib/better_service/concerns/serviceable/presentable.rb +66 -0
- data/lib/better_service/concerns/serviceable/transactional.rb +51 -0
- data/lib/better_service/concerns/serviceable/validatable.rb +58 -0
- data/lib/better_service/concerns/serviceable/viewable.rb +49 -0
- data/lib/better_service/concerns/serviceable.rb +12 -0
- data/lib/better_service/concerns/workflowable/callbacks.rb +116 -0
- data/lib/better_service/concerns/workflowable/context.rb +108 -0
- data/lib/better_service/concerns/workflowable/step.rb +141 -0
- data/lib/better_service/concerns/workflowable.rb +12 -0
- data/lib/better_service/configuration.rb +113 -0
- data/lib/better_service/errors/better_service_error.rb +271 -0
- data/lib/better_service/errors/configuration/configuration_error.rb +21 -0
- data/lib/better_service/errors/configuration/invalid_configuration_error.rb +28 -0
- data/lib/better_service/errors/configuration/invalid_schema_error.rb +28 -0
- data/lib/better_service/errors/configuration/nil_user_error.rb +37 -0
- data/lib/better_service/errors/configuration/schema_required_error.rb +29 -0
- data/lib/better_service/errors/runtime/authorization_error.rb +38 -0
- data/lib/better_service/errors/runtime/database_error.rb +38 -0
- data/lib/better_service/errors/runtime/execution_error.rb +27 -0
- data/lib/better_service/errors/runtime/resource_not_found_error.rb +38 -0
- data/lib/better_service/errors/runtime/runtime_error.rb +22 -0
- data/lib/better_service/errors/runtime/transaction_error.rb +34 -0
- data/lib/better_service/errors/runtime/validation_error.rb +42 -0
- data/lib/better_service/errors/workflowable/configuration/duplicate_step_error.rb +27 -0
- data/lib/better_service/errors/workflowable/configuration/invalid_step_error.rb +12 -0
- data/lib/better_service/errors/workflowable/configuration/step_not_found_error.rb +29 -0
- data/lib/better_service/errors/workflowable/configuration/workflow_configuration_error.rb +24 -0
- data/lib/better_service/errors/workflowable/runtime/rollback_error.rb +46 -0
- data/lib/better_service/errors/workflowable/runtime/step_execution_error.rb +47 -0
- data/lib/better_service/errors/workflowable/runtime/workflow_execution_error.rb +40 -0
- data/lib/better_service/errors/workflowable/runtime/workflow_runtime_error.rb +25 -0
- data/lib/better_service/railtie.rb +6 -0
- data/lib/better_service/services/action_service.rb +60 -0
- data/lib/better_service/services/base.rb +249 -0
- data/lib/better_service/services/create_service.rb +60 -0
- data/lib/better_service/services/destroy_service.rb +57 -0
- data/lib/better_service/services/index_service.rb +56 -0
- data/lib/better_service/services/show_service.rb +44 -0
- data/lib/better_service/services/update_service.rb +58 -0
- data/lib/better_service/subscribers/log_subscriber.rb +131 -0
- data/lib/better_service/subscribers/stats_subscriber.rb +208 -0
- data/lib/better_service/version.rb +3 -0
- data/lib/better_service/workflows/base.rb +106 -0
- data/lib/better_service/workflows/dsl.rb +59 -0
- data/lib/better_service/workflows/execution.rb +89 -0
- data/lib/better_service/workflows/result_builder.rb +67 -0
- data/lib/better_service/workflows/rollback_support.rb +44 -0
- data/lib/better_service/workflows/transaction_support.rb +32 -0
- data/lib/better_service.rb +28 -0
- data/lib/generators/serviceable/action_generator.rb +29 -0
- data/lib/generators/serviceable/create_generator.rb +27 -0
- data/lib/generators/serviceable/destroy_generator.rb +27 -0
- data/lib/generators/serviceable/index_generator.rb +27 -0
- data/lib/generators/serviceable/scaffold_generator.rb +70 -0
- data/lib/generators/serviceable/show_generator.rb +27 -0
- data/lib/generators/serviceable/templates/action_service.rb.tt +42 -0
- data/lib/generators/serviceable/templates/create_service.rb.tt +33 -0
- data/lib/generators/serviceable/templates/destroy_service.rb.tt +40 -0
- data/lib/generators/serviceable/templates/index_service.rb.tt +54 -0
- data/lib/generators/serviceable/templates/service_test.rb.tt +23 -0
- data/lib/generators/serviceable/templates/show_service.rb.tt +37 -0
- data/lib/generators/serviceable/templates/update_service.rb.tt +50 -0
- data/lib/generators/serviceable/update_generator.rb +27 -0
- data/lib/generators/workflowable/WORKFLOW_README +27 -0
- data/lib/generators/workflowable/templates/workflow.rb.tt +72 -0
- data/lib/generators/workflowable/templates/workflow_test.rb.tt +62 -0
- data/lib/generators/workflowable/workflow_generator.rb +60 -0
- data/lib/tasks/better_service_tasks.rake +4 -0
- metadata +180 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module BetterService
|
|
6
|
+
module Concerns
|
|
7
|
+
module Serviceable
|
|
8
|
+
module Cacheable
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
class_attribute :_cache_key, default: nil
|
|
13
|
+
class_attribute :_cache_ttl, default: 900 # 15 minutes in seconds
|
|
14
|
+
class_attribute :_cache_contexts, default: []
|
|
15
|
+
|
|
16
|
+
# Wrap call method with caching after class is loaded
|
|
17
|
+
# This is safe because included runs after Base defines call
|
|
18
|
+
if method_defined?(:call)
|
|
19
|
+
alias_method :call_without_cache, :call
|
|
20
|
+
|
|
21
|
+
define_method(:call) do
|
|
22
|
+
return call_without_cache unless cache_enabled?
|
|
23
|
+
|
|
24
|
+
cache_key_value = build_cache_key(user)
|
|
25
|
+
|
|
26
|
+
# Check if cache exists to publish appropriate event
|
|
27
|
+
cache_exists = Rails.cache.exist?(cache_key_value)
|
|
28
|
+
|
|
29
|
+
# Get cache context if available
|
|
30
|
+
cache_context = self.class._cache_contexts.first
|
|
31
|
+
|
|
32
|
+
# Publish cache hit/miss event if instrumentation methods are available
|
|
33
|
+
# These methods are added by Instrumentation concern which is prepended after
|
|
34
|
+
begin
|
|
35
|
+
if cache_exists
|
|
36
|
+
publish_cache_hit(cache_key_value, cache_context)
|
|
37
|
+
else
|
|
38
|
+
publish_cache_miss(cache_key_value, cache_context)
|
|
39
|
+
end
|
|
40
|
+
rescue NoMethodError
|
|
41
|
+
# Instrumentation methods not available - skip event publishing silently
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Rails.cache.fetch(cache_key_value, expires_in: self.class._cache_ttl) do
|
|
45
|
+
call_without_cache
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class_methods do
|
|
52
|
+
def cache_key(key)
|
|
53
|
+
self._cache_key = key
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cache_ttl(duration)
|
|
57
|
+
self._cache_ttl = duration
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cache_contexts(*contexts)
|
|
61
|
+
self._cache_contexts = contexts
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def cache_enabled?
|
|
68
|
+
self.class._cache_key.present?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_cache_key(user)
|
|
72
|
+
user_part = user ? "user_#{user.id}" : "global"
|
|
73
|
+
"#{self.class._cache_key}:#{user_part}:#{cache_params_signature}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def cache_params_signature
|
|
77
|
+
Digest::MD5.hexdigest(@params.to_json)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def invalidate_cache_for(user = nil)
|
|
81
|
+
return unless self.class._cache_contexts.present?
|
|
82
|
+
|
|
83
|
+
if user.present?
|
|
84
|
+
self.class._cache_contexts.each do |context|
|
|
85
|
+
CacheService.invalidate_for_context(user, context)
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
Rails.logger.info "[BetterService] Global cache invalidation for: #{self.class._cache_contexts.join(', ')}" if defined?(Rails)
|
|
89
|
+
self.class._cache_contexts.each do |context|
|
|
90
|
+
CacheService.invalidate_global(context)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Concerns
|
|
5
|
+
module Serviceable
|
|
6
|
+
module Messageable
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :_messages_namespace, default: nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class_methods do
|
|
14
|
+
def messages_namespace(namespace)
|
|
15
|
+
self._messages_namespace = namespace
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def message(key_path, interpolations = {})
|
|
22
|
+
return key_path if self.class._messages_namespace.nil?
|
|
23
|
+
|
|
24
|
+
full_key = "#{self.class._messages_namespace}.services.#{key_path}"
|
|
25
|
+
I18n.t(full_key, **interpolations)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Concerns
|
|
5
|
+
module Serviceable
|
|
6
|
+
module Presentable
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :_presenter_class, default: nil
|
|
11
|
+
class_attribute :_presenter_options, default: -> { {} }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
def presenter(klass)
|
|
16
|
+
self._presenter_class = klass
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def presenter_options(&block)
|
|
20
|
+
self._presenter_options = block
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Override transform phase
|
|
27
|
+
def transform(data)
|
|
28
|
+
# If custom transform_with block defined, use it
|
|
29
|
+
if self.class._transform_block
|
|
30
|
+
instance_exec(data, &self.class._transform_block)
|
|
31
|
+
# If presenter configured, apply it
|
|
32
|
+
elsif self.class._presenter_class
|
|
33
|
+
apply_presenter(data)
|
|
34
|
+
# Otherwise return data unchanged
|
|
35
|
+
else
|
|
36
|
+
data
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def apply_presenter(data)
|
|
41
|
+
options = instance_exec(&self.class._presenter_options)
|
|
42
|
+
|
|
43
|
+
if data.key?(:items)
|
|
44
|
+
rest = data.dup
|
|
45
|
+
rest.delete(:items)
|
|
46
|
+
{ items: present_collection(data[:items], options), **rest }
|
|
47
|
+
elsif data.key?(:resource)
|
|
48
|
+
rest = data.dup
|
|
49
|
+
rest.delete(:resource)
|
|
50
|
+
{ resource: present_resource(data[:resource], options), **rest }
|
|
51
|
+
else
|
|
52
|
+
data
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def present_collection(items, options)
|
|
57
|
+
items.map { |item| self.class._presenter_class.new(item, **options) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def present_resource(resource, options)
|
|
61
|
+
self.class._presenter_class.new(resource, **options)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Concerns
|
|
5
|
+
module Serviceable
|
|
6
|
+
module Transactional
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :_with_transaction, default: false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Hook for prepend (same as included but triggered by prepend)
|
|
14
|
+
def self.prepended(base)
|
|
15
|
+
base.class_attribute :_with_transaction, default: false
|
|
16
|
+
base.extend(ClassMethods)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module ClassMethods
|
|
20
|
+
# Enable or disable database transactions for this service
|
|
21
|
+
#
|
|
22
|
+
# @param value [Boolean] whether to wrap process in a transaction
|
|
23
|
+
#
|
|
24
|
+
# @example Enable transactions
|
|
25
|
+
# class Booking::CreateService < BetterService::CreateService
|
|
26
|
+
# with_transaction true
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @example Disable transactions
|
|
30
|
+
# class Booking::ImportService < BetterService::CreateService
|
|
31
|
+
# with_transaction false # Disable inherited transaction
|
|
32
|
+
# end
|
|
33
|
+
def with_transaction(value)
|
|
34
|
+
self._with_transaction = value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Override process to wrap in transaction if enabled
|
|
39
|
+
def process(data)
|
|
40
|
+
return super(data) unless self.class._with_transaction
|
|
41
|
+
|
|
42
|
+
result = nil
|
|
43
|
+
ActiveRecord::Base.transaction do
|
|
44
|
+
result = super(data)
|
|
45
|
+
end
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-schema"
|
|
4
|
+
|
|
5
|
+
module BetterService
|
|
6
|
+
module Concerns
|
|
7
|
+
module Serviceable
|
|
8
|
+
module Validatable
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
class_attribute :_schema, default: nil
|
|
13
|
+
attr_reader :validation_errors
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
def schema(&block)
|
|
18
|
+
self._schema = Dry::Schema.Params(&block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def schema_defined?
|
|
22
|
+
_schema.present?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def valid?
|
|
27
|
+
@validation_errors.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def validate_params!
|
|
33
|
+
return unless self.class._schema
|
|
34
|
+
|
|
35
|
+
result = self.class._schema.call(@params)
|
|
36
|
+
return if result.success?
|
|
37
|
+
|
|
38
|
+
# Raise ValidationError instead of setting validation_errors
|
|
39
|
+
raise Errors::Runtime::ValidationError.new(
|
|
40
|
+
"Validation failed",
|
|
41
|
+
code: ErrorCodes::VALIDATION_FAILED,
|
|
42
|
+
context: {
|
|
43
|
+
service: self.class.name,
|
|
44
|
+
params: @params,
|
|
45
|
+
validation_errors: format_validation_errors(result.errors)
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def format_validation_errors(errors)
|
|
51
|
+
errors.to_h.transform_values do |messages|
|
|
52
|
+
messages.is_a?(Array) ? messages : [ messages ]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Concerns
|
|
5
|
+
module Serviceable
|
|
6
|
+
module Viewable
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :_viewer_enabled, default: false
|
|
11
|
+
class_attribute :_viewer_block, default: nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
def viewer(enabled = true, &block)
|
|
16
|
+
self._viewer_enabled = enabled
|
|
17
|
+
self._viewer_block = block if block_given?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
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
|
+
def viewer_enabled?
|
|
40
|
+
self.class._viewer_enabled && self.class._viewer_block.present?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute_viewer(processed, transformed, result)
|
|
44
|
+
instance_exec(processed, transformed, result, &self.class._viewer_block)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Concerns
|
|
5
|
+
# Serviceable - Namespace for all service-related concerns
|
|
6
|
+
#
|
|
7
|
+
# This module groups all concerns that are specific to BetterService services,
|
|
8
|
+
# such as validation, authorization, transactions, presentation, caching, etc.
|
|
9
|
+
module Serviceable
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Concerns
|
|
5
|
+
module Workflowable
|
|
6
|
+
# Callbacks - Adds lifecycle callbacks to workflows
|
|
7
|
+
#
|
|
8
|
+
# Provides before_workflow, after_workflow, and around_step hooks
|
|
9
|
+
# that allow executing custom logic at different stages of the workflow.
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# class OrderWorkflow < BetterService::Workflow
|
|
13
|
+
# before_workflow :validate_prerequisites
|
|
14
|
+
# after_workflow :cleanup_resources
|
|
15
|
+
# around_step :log_step_execution
|
|
16
|
+
#
|
|
17
|
+
# private
|
|
18
|
+
#
|
|
19
|
+
# def validate_prerequisites(context)
|
|
20
|
+
# context.fail!("Cart is empty") if context.cart_items.empty?
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# def cleanup_resources(context)
|
|
24
|
+
# context.user.clear_cart! if context.success?
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# def log_step_execution(step, context)
|
|
28
|
+
# start_time = Time.current
|
|
29
|
+
# yield # Execute the step
|
|
30
|
+
# duration = Time.current - start_time
|
|
31
|
+
# Rails.logger.info "Step #{step.name} completed in #{duration}s"
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
module Callbacks
|
|
35
|
+
extend ActiveSupport::Concern
|
|
36
|
+
|
|
37
|
+
included do
|
|
38
|
+
class_attribute :_before_workflow_callbacks, default: []
|
|
39
|
+
class_attribute :_after_workflow_callbacks, default: []
|
|
40
|
+
class_attribute :_around_step_callbacks, default: []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class_methods do
|
|
44
|
+
# Define a callback to run before the workflow starts
|
|
45
|
+
#
|
|
46
|
+
# @param method_name [Symbol] Name of the method to call
|
|
47
|
+
def before_workflow(method_name)
|
|
48
|
+
self._before_workflow_callbacks += [ method_name ]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Define a callback to run after the workflow completes
|
|
52
|
+
#
|
|
53
|
+
# @param method_name [Symbol] Name of the method to call
|
|
54
|
+
def after_workflow(method_name)
|
|
55
|
+
self._after_workflow_callbacks += [ method_name ]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Define a callback to run around each step execution
|
|
59
|
+
#
|
|
60
|
+
# @param method_name [Symbol] Name of the method to call
|
|
61
|
+
# The method will receive the step and context as arguments
|
|
62
|
+
# and should yield to execute the step
|
|
63
|
+
def around_step(method_name)
|
|
64
|
+
self._around_step_callbacks += [ method_name ]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Run all before_workflow callbacks
|
|
71
|
+
def run_before_workflow_callbacks(context)
|
|
72
|
+
self.class._before_workflow_callbacks.each do |callback|
|
|
73
|
+
send(callback, context)
|
|
74
|
+
# Stop execution if context was marked as failed
|
|
75
|
+
break if context.failure?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Run all after_workflow callbacks
|
|
80
|
+
def run_after_workflow_callbacks(context)
|
|
81
|
+
self.class._after_workflow_callbacks.each do |callback|
|
|
82
|
+
send(callback, context)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Run around_step callbacks for a specific step
|
|
87
|
+
def run_around_step_callbacks(step, context, &block)
|
|
88
|
+
callbacks = self.class._around_step_callbacks.dup
|
|
89
|
+
|
|
90
|
+
if callbacks.empty?
|
|
91
|
+
# No callbacks, just execute the block
|
|
92
|
+
yield
|
|
93
|
+
else
|
|
94
|
+
# Build a chain of callbacks
|
|
95
|
+
execute_callback_chain(callbacks, step, context, &block)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Execute callback chain recursively
|
|
100
|
+
def execute_callback_chain(callbacks, step, context, &block)
|
|
101
|
+
if callbacks.empty?
|
|
102
|
+
# End of chain, execute the actual step
|
|
103
|
+
yield
|
|
104
|
+
else
|
|
105
|
+
# Pop the first callback and execute it
|
|
106
|
+
callback = callbacks.shift
|
|
107
|
+
send(callback, step, context) do
|
|
108
|
+
# Continue the chain
|
|
109
|
+
execute_callback_chain(callbacks, step, context, &block)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Workflowable
|
|
5
|
+
# Context - Container for shared data between workflow steps
|
|
6
|
+
#
|
|
7
|
+
# The Context object holds all data that flows through the workflow pipeline.
|
|
8
|
+
# Each step can read from and write to the context. The context also tracks
|
|
9
|
+
# the success/failure state of the workflow.
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# context = Context.new(user: current_user, cart_items: [...])
|
|
13
|
+
# context.order = Order.create!(...)
|
|
14
|
+
# context.success? # => true
|
|
15
|
+
#
|
|
16
|
+
# context.fail!("Payment failed", payment_error: "Card declined")
|
|
17
|
+
# context.success? # => false
|
|
18
|
+
# context.errors # => { payment_error: "Card declined" }
|
|
19
|
+
class Context
|
|
20
|
+
attr_reader :user, :errors, :_called
|
|
21
|
+
|
|
22
|
+
def initialize(user, **initial_data)
|
|
23
|
+
@user = user
|
|
24
|
+
@data = initial_data
|
|
25
|
+
@errors = {}
|
|
26
|
+
@failed = false
|
|
27
|
+
@_called = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Check if workflow has succeeded (no failure called)
|
|
31
|
+
def success?
|
|
32
|
+
!@failed
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if workflow has failed
|
|
36
|
+
def failure?
|
|
37
|
+
@failed
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Mark workflow as failed with error message and optional error details
|
|
41
|
+
#
|
|
42
|
+
# @param message [String] Error message
|
|
43
|
+
# @param errors [Hash] Optional hash of detailed errors
|
|
44
|
+
def fail!(message, **errors)
|
|
45
|
+
@failed = true
|
|
46
|
+
@errors[:message] = message
|
|
47
|
+
@errors.merge!(errors) if errors.any?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Mark workflow as called (used internally)
|
|
51
|
+
def called!
|
|
52
|
+
@_called = true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if workflow has been called
|
|
56
|
+
def called?
|
|
57
|
+
@_called
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Add data to context
|
|
61
|
+
#
|
|
62
|
+
# @param key [Symbol] Key to store data under
|
|
63
|
+
# @param value [Object] Value to store
|
|
64
|
+
def add(key, value)
|
|
65
|
+
@data[key] = value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get data from context
|
|
69
|
+
#
|
|
70
|
+
# @param key [Symbol] Key to retrieve
|
|
71
|
+
def get(key)
|
|
72
|
+
@data[key]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Allow reading context data via method calls
|
|
76
|
+
# Example: context.order instead of context.get(:order)
|
|
77
|
+
def method_missing(method_name, *args)
|
|
78
|
+
method_str = method_name.to_s
|
|
79
|
+
|
|
80
|
+
if method_str.end_with?("=")
|
|
81
|
+
# Setter: context.order = value
|
|
82
|
+
key = method_str.chomp("=").to_sym
|
|
83
|
+
@data[key] = args.first
|
|
84
|
+
elsif @data.key?(method_name)
|
|
85
|
+
# Getter: context.order
|
|
86
|
+
@data[method_name]
|
|
87
|
+
else
|
|
88
|
+
super
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
93
|
+
method_str = method_name.to_s
|
|
94
|
+
method_str.end_with?("=") || @data.key?(method_name) || super
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Return all context data as hash
|
|
98
|
+
def to_h
|
|
99
|
+
@data.dup
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Inspect for debugging
|
|
103
|
+
def inspect
|
|
104
|
+
"#<BetterService::Workflowable::Context success=#{success?} data=#{@data.inspect} errors=#{@errors.inspect}>"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|