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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1321 -0
  4. data/Rakefile +15 -0
  5. data/lib/better_service/cache_service.rb +310 -0
  6. data/lib/better_service/concerns/instrumentation.rb +242 -0
  7. data/lib/better_service/concerns/serviceable/authorizable.rb +106 -0
  8. data/lib/better_service/concerns/serviceable/cacheable.rb +97 -0
  9. data/lib/better_service/concerns/serviceable/messageable.rb +30 -0
  10. data/lib/better_service/concerns/serviceable/presentable.rb +66 -0
  11. data/lib/better_service/concerns/serviceable/transactional.rb +51 -0
  12. data/lib/better_service/concerns/serviceable/validatable.rb +58 -0
  13. data/lib/better_service/concerns/serviceable/viewable.rb +49 -0
  14. data/lib/better_service/concerns/serviceable.rb +12 -0
  15. data/lib/better_service/concerns/workflowable/callbacks.rb +116 -0
  16. data/lib/better_service/concerns/workflowable/context.rb +108 -0
  17. data/lib/better_service/concerns/workflowable/step.rb +141 -0
  18. data/lib/better_service/concerns/workflowable.rb +12 -0
  19. data/lib/better_service/configuration.rb +113 -0
  20. data/lib/better_service/errors/better_service_error.rb +271 -0
  21. data/lib/better_service/errors/configuration/configuration_error.rb +21 -0
  22. data/lib/better_service/errors/configuration/invalid_configuration_error.rb +28 -0
  23. data/lib/better_service/errors/configuration/invalid_schema_error.rb +28 -0
  24. data/lib/better_service/errors/configuration/nil_user_error.rb +37 -0
  25. data/lib/better_service/errors/configuration/schema_required_error.rb +29 -0
  26. data/lib/better_service/errors/runtime/authorization_error.rb +38 -0
  27. data/lib/better_service/errors/runtime/database_error.rb +38 -0
  28. data/lib/better_service/errors/runtime/execution_error.rb +27 -0
  29. data/lib/better_service/errors/runtime/resource_not_found_error.rb +38 -0
  30. data/lib/better_service/errors/runtime/runtime_error.rb +22 -0
  31. data/lib/better_service/errors/runtime/transaction_error.rb +34 -0
  32. data/lib/better_service/errors/runtime/validation_error.rb +42 -0
  33. data/lib/better_service/errors/workflowable/configuration/duplicate_step_error.rb +27 -0
  34. data/lib/better_service/errors/workflowable/configuration/invalid_step_error.rb +12 -0
  35. data/lib/better_service/errors/workflowable/configuration/step_not_found_error.rb +29 -0
  36. data/lib/better_service/errors/workflowable/configuration/workflow_configuration_error.rb +24 -0
  37. data/lib/better_service/errors/workflowable/runtime/rollback_error.rb +46 -0
  38. data/lib/better_service/errors/workflowable/runtime/step_execution_error.rb +47 -0
  39. data/lib/better_service/errors/workflowable/runtime/workflow_execution_error.rb +40 -0
  40. data/lib/better_service/errors/workflowable/runtime/workflow_runtime_error.rb +25 -0
  41. data/lib/better_service/railtie.rb +6 -0
  42. data/lib/better_service/services/action_service.rb +60 -0
  43. data/lib/better_service/services/base.rb +249 -0
  44. data/lib/better_service/services/create_service.rb +60 -0
  45. data/lib/better_service/services/destroy_service.rb +57 -0
  46. data/lib/better_service/services/index_service.rb +56 -0
  47. data/lib/better_service/services/show_service.rb +44 -0
  48. data/lib/better_service/services/update_service.rb +58 -0
  49. data/lib/better_service/subscribers/log_subscriber.rb +131 -0
  50. data/lib/better_service/subscribers/stats_subscriber.rb +208 -0
  51. data/lib/better_service/version.rb +3 -0
  52. data/lib/better_service/workflows/base.rb +106 -0
  53. data/lib/better_service/workflows/dsl.rb +59 -0
  54. data/lib/better_service/workflows/execution.rb +89 -0
  55. data/lib/better_service/workflows/result_builder.rb +67 -0
  56. data/lib/better_service/workflows/rollback_support.rb +44 -0
  57. data/lib/better_service/workflows/transaction_support.rb +32 -0
  58. data/lib/better_service.rb +28 -0
  59. data/lib/generators/serviceable/action_generator.rb +29 -0
  60. data/lib/generators/serviceable/create_generator.rb +27 -0
  61. data/lib/generators/serviceable/destroy_generator.rb +27 -0
  62. data/lib/generators/serviceable/index_generator.rb +27 -0
  63. data/lib/generators/serviceable/scaffold_generator.rb +70 -0
  64. data/lib/generators/serviceable/show_generator.rb +27 -0
  65. data/lib/generators/serviceable/templates/action_service.rb.tt +42 -0
  66. data/lib/generators/serviceable/templates/create_service.rb.tt +33 -0
  67. data/lib/generators/serviceable/templates/destroy_service.rb.tt +40 -0
  68. data/lib/generators/serviceable/templates/index_service.rb.tt +54 -0
  69. data/lib/generators/serviceable/templates/service_test.rb.tt +23 -0
  70. data/lib/generators/serviceable/templates/show_service.rb.tt +37 -0
  71. data/lib/generators/serviceable/templates/update_service.rb.tt +50 -0
  72. data/lib/generators/serviceable/update_generator.rb +27 -0
  73. data/lib/generators/workflowable/WORKFLOW_README +27 -0
  74. data/lib/generators/workflowable/templates/workflow.rb.tt +72 -0
  75. data/lib/generators/workflowable/templates/workflow_test.rb.tt +62 -0
  76. data/lib/generators/workflowable/workflow_generator.rb +60 -0
  77. data/lib/tasks/better_service_tasks.rake +4 -0
  78. 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