better_service 2.0.0 → 2.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +2 -0
  3. data/README.md +98 -45
  4. data/Rakefile +7 -209
  5. data/config/locales/better_service.en.yml +15 -0
  6. data/lib/better_service/cache_service.rb +4 -4
  7. data/lib/better_service/concerns/instrumentation.rb +59 -14
  8. data/lib/better_service/concerns/serviceable/authorizable.rb +1 -1
  9. data/lib/better_service/concerns/serviceable/messageable.rb +70 -1
  10. data/lib/better_service/concerns/serviceable/repository_aware.rb +8 -3
  11. data/lib/better_service/concerns/workflowable/callbacks.rb +27 -27
  12. data/lib/better_service/concerns/workflowable/step.rb +39 -5
  13. data/lib/better_service/errors/better_service_error.rb +4 -0
  14. data/lib/better_service/errors/runtime/authorization_error.rb +4 -1
  15. data/lib/better_service/errors/runtime/database_error.rb +4 -1
  16. data/lib/better_service/errors/runtime/execution_error.rb +4 -1
  17. data/lib/better_service/errors/runtime/invalid_result_error.rb +28 -0
  18. data/lib/better_service/errors/runtime/resource_not_found_error.rb +4 -1
  19. data/lib/better_service/errors/runtime/validation_error.rb +4 -1
  20. data/lib/better_service/repository/base_repository.rb +1 -1
  21. data/lib/better_service/result.rb +110 -0
  22. data/lib/better_service/services/base.rb +216 -57
  23. data/lib/better_service/version.rb +1 -1
  24. data/lib/better_service/workflows/branch_group.rb +1 -1
  25. data/lib/better_service.rb +1 -6
  26. data/lib/generators/serviceable/action_generator.rb +11 -0
  27. data/lib/generators/serviceable/base_generator.rb +109 -0
  28. data/lib/generators/serviceable/create_generator.rb +11 -0
  29. data/lib/generators/serviceable/destroy_generator.rb +11 -0
  30. data/lib/generators/serviceable/index_generator.rb +11 -0
  31. data/lib/generators/serviceable/scaffold_generator.rb +29 -7
  32. data/lib/generators/serviceable/show_generator.rb +11 -0
  33. data/lib/generators/serviceable/templates/action_service.rb.tt +8 -3
  34. data/lib/generators/serviceable/templates/base_locale.en.yml.tt +53 -0
  35. data/lib/generators/serviceable/templates/base_service.rb.tt +78 -0
  36. data/lib/generators/serviceable/templates/base_service_test.rb.tt +64 -0
  37. data/lib/generators/serviceable/templates/create_service.rb.tt +29 -18
  38. data/lib/generators/serviceable/templates/destroy_service.rb.tt +16 -29
  39. data/lib/generators/serviceable/templates/index_service.rb.tt +16 -34
  40. data/lib/generators/serviceable/templates/repository.rb.tt +76 -0
  41. data/lib/generators/serviceable/templates/repository_test.rb.tt +124 -0
  42. data/lib/generators/serviceable/templates/show_service.rb.tt +10 -38
  43. data/lib/generators/serviceable/templates/update_service.rb.tt +24 -38
  44. data/lib/generators/serviceable/update_generator.rb +11 -0
  45. metadata +13 -12
  46. data/lib/better_service/concerns/serviceable/viewable.rb +0 -33
  47. data/lib/better_service/services/action_service.rb +0 -60
  48. data/lib/better_service/services/create_service.rb +0 -63
  49. data/lib/better_service/services/destroy_service.rb +0 -60
  50. data/lib/better_service/services/index_service.rb +0 -56
  51. data/lib/better_service/services/show_service.rb +0 -44
  52. data/lib/better_service/services/update_service.rb +0 -61
@@ -43,7 +43,7 @@ module BetterService
43
43
  fallback_key = "better_service.services.default.#{action}"
44
44
 
45
45
  # I18n supports array of fallback keys: try each in order
46
- I18n.t(full_key, default: [fallback_key.to_sym, key_path], **interpolations)
46
+ I18n.t(full_key, default: [ fallback_key.to_sym, key_path ], **interpolations)
47
47
  end
48
48
 
49
49
  # Extract action name from key path for fallback lookup
@@ -67,6 +67,75 @@ module BetterService
67
67
  else "action_completed"
68
68
  end
69
69
  end
70
+
71
+ # ============================================
72
+ # RESPONSE HELPERS FOR TUPLE FORMAT
73
+ # ============================================
74
+
75
+ # Build a failure response hash for validation failures
76
+ # Use this when using save (not save!) to handle AR validation gracefully
77
+ #
78
+ # @param record [ActiveRecord::Base] The record with validation errors
79
+ # @param custom_message [String, nil] Optional custom message
80
+ # @return [Hash] Failure response hash (for use in process_with/respond_with)
81
+ #
82
+ # @example In process_with block
83
+ # process_with do |data|
84
+ # product = user.products.build(params)
85
+ #
86
+ # if product.save
87
+ # { object: product }
88
+ # else
89
+ # failure_for(product)
90
+ # end
91
+ # end
92
+ def failure_for(record, custom_message = nil)
93
+ {
94
+ object: record,
95
+ success: false,
96
+ message: custom_message || default_failure_message(record)
97
+ }
98
+ end
99
+
100
+ # Build a success response hash
101
+ #
102
+ # @param object [Object] The object to return (AR model, array, etc.)
103
+ # @param custom_message [String, nil] Optional custom message
104
+ # @return [Hash] Success response hash (for use in respond_with)
105
+ #
106
+ # @example In respond_with block
107
+ # respond_with do |data|
108
+ # return data if data[:success] == false
109
+ # success_for(data[:object], "Product created!")
110
+ # end
111
+ def success_for(object, custom_message = nil)
112
+ {
113
+ object: object,
114
+ success: true,
115
+ message: custom_message || default_success_message
116
+ }
117
+ end
118
+
119
+ # Generate default failure message based on record state
120
+ #
121
+ # @param record [ActiveRecord::Base] The failed record
122
+ # @return [String] Failure message
123
+ def default_failure_message(record)
124
+ model_name = record.class.name.underscore.humanize.downcase
125
+ if record.new_record?
126
+ message("create.failure", default: "Failed to create #{model_name}")
127
+ else
128
+ message("update.failure", default: "Failed to update #{model_name}")
129
+ end
130
+ end
131
+
132
+ # Generate default success message based on action
133
+ #
134
+ # @return [String] Success message
135
+ def default_success_message
136
+ action = self.class._action_name || :action
137
+ message("#{action}.success", default: "Operation completed successfully")
138
+ end
70
139
  end
71
140
  end
72
141
  end
@@ -10,9 +10,12 @@ module BetterService
10
10
  # separation between business logic and data access.
11
11
  #
12
12
  # @example Basic usage
13
- # class Products::CreateService < BetterService::Services::CreateService
13
+ # class Products::CreateService < Products::BaseService
14
14
  # include BetterService::Concerns::Serviceable::RepositoryAware
15
15
  #
16
+ # performed_action :created
17
+ # with_transaction true
18
+ #
16
19
  # repository :product
17
20
  #
18
21
  # process_with do |data|
@@ -21,7 +24,7 @@ module BetterService
21
24
  # end
22
25
  #
23
26
  # @example With custom class name
24
- # class Bookings::AcceptService < BetterService::Services::ActionService
27
+ # class Bookings::AcceptService < Bookings::BaseService
25
28
  # include BetterService::Concerns::Serviceable::RepositoryAware
26
29
  #
27
30
  # repository :booking, class_name: "Bookings::BookingRepository"
@@ -33,9 +36,11 @@ module BetterService
33
36
  # end
34
37
  #
35
38
  # @example Multiple repositories shorthand
36
- # class Dashboard::IndexService < BetterService::Services::IndexService
39
+ # class Dashboard::IndexService < Dashboard::BaseService
37
40
  # include BetterService::Concerns::Serviceable::RepositoryAware
38
41
  #
42
+ # performed_action :listed
43
+ #
39
44
  # repositories :user, :booking, :payment
40
45
  # end
41
46
  #
@@ -4,33 +4,33 @@ module BetterService
4
4
  module Concerns
5
5
  module Workflowable
6
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
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
34
  module Callbacks
35
35
  extend ActiveSupport::Concern
36
36
 
@@ -35,7 +35,7 @@ module BetterService
35
35
  # @param context [Context] The workflow context
36
36
  # @param user [Object] The current user
37
37
  # @param params [Hash] Base params for the workflow
38
- # @return [Hash] Service result
38
+ # @return [Hash] Service result (normalized to hash format for workflow compatibility)
39
39
  def call(context, user, base_params = {})
40
40
  # Check if step should be skipped due to condition
41
41
  if should_skip?(context)
@@ -49,20 +49,23 @@ module BetterService
49
49
  # Build input params for the service
50
50
  service_params = build_params(context, base_params)
51
51
 
52
- # Call the service
53
- result = service_class.new(user, params: service_params).call
52
+ # Call the service - returns [object, metadata] tuple
53
+ service_result = service_class.new(user, params: service_params).call
54
+
55
+ # Normalize result to hash format (services now return [object, metadata] tuple)
56
+ result = normalize_service_result(service_result)
54
57
 
55
58
  # Store result in context if successful
56
59
  if result[:success]
57
60
  store_result_in_context(context, result)
58
61
  elsif optional
59
62
  # If step is optional and failed, continue but log the failure
60
- context.add(:"#{name}_error", result[:errors])
63
+ context.add(:"#{name}_error", result[:errors] || result[:validation_errors])
61
64
  return {
62
65
  success: true,
63
66
  optional_failure: true,
64
67
  message: "Optional step #{name} failed but continuing",
65
- errors: result[:errors]
68
+ errors: result[:errors] || result[:validation_errors]
66
69
  }
67
70
  end
68
71
 
@@ -123,6 +126,37 @@ module BetterService
123
126
  end
124
127
  end
125
128
 
129
+ # Normalize service result from Result format to hash format
130
+ # Services return BetterService::Result but workflows expect hash with :success, :resource, etc.
131
+ #
132
+ # @param service_result [BetterService::Result] The service result
133
+ # @return [Hash] Normalized result hash
134
+ # @raise [BetterService::Errors::Runtime::InvalidResultError] If result is not a BetterService::Result
135
+ def normalize_service_result(service_result)
136
+ unless service_result.is_a?(BetterService::Result)
137
+ raise BetterService::Errors::Runtime::InvalidResultError.new(
138
+ "Step #{name} service must return BetterService::Result, got #{service_result.class}",
139
+ context: { step: name, service: service_class.name, result_class: service_result.class.name }
140
+ )
141
+ end
142
+
143
+ object = service_result.resource
144
+ metadata = service_result.meta
145
+
146
+ # Build normalized hash result
147
+ result = metadata.dup
148
+ result[:success] = metadata[:success] if metadata.key?(:success)
149
+
150
+ # Store object appropriately based on type
151
+ if object.is_a?(Array)
152
+ result[:items] = object
153
+ elsif object.present?
154
+ result[:resource] = object
155
+ end
156
+
157
+ result
158
+ end
159
+
126
160
  # Store successful result data in context
127
161
  def store_result_in_context(context, result)
128
162
  # Store resource if present
@@ -218,6 +218,9 @@ module BetterService
218
218
 
219
219
  # Workflow rollback failed
220
220
  ROLLBACK_FAILED = :rollback_failed
221
+
222
+ # Service returned invalid result type (not BetterService::Result)
223
+ INVALID_RESULT = :invalid_result
221
224
  end
222
225
  end
223
226
 
@@ -235,6 +238,7 @@ require_relative "runtime/resource_not_found_error"
235
238
  require_relative "runtime/database_error"
236
239
  require_relative "runtime/validation_error"
237
240
  require_relative "runtime/authorization_error"
241
+ require_relative "runtime/invalid_result_error"
238
242
 
239
243
  require_relative "workflowable/configuration/workflow_configuration_error"
240
244
  require_relative "workflowable/configuration/step_not_found_error"
@@ -31,7 +31,10 @@ module BetterService
31
31
  # rescue BetterService::Errors::Runtime::AuthorizationError => e
32
32
  # render json: { error: e.message }, status: :forbidden
33
33
  # end
34
- class AuthorizationError < RuntimeError
34
+ class AuthorizationError < BetterService::Errors::Runtime::RuntimeError
35
+ def initialize(message = "Not authorized", code: :unauthorized, context: {}, original_error: nil)
36
+ super(message, code: code, context: context, original_error: original_error)
37
+ end
35
38
  end
36
39
  end
37
40
  end
@@ -31,7 +31,10 @@ module BetterService
31
31
  #
32
32
  # MyService.new(user, params: { user_id: 1 }).call
33
33
  # # => raises DatabaseError
34
- class DatabaseError < RuntimeError
34
+ class DatabaseError < BetterService::Errors::Runtime::RuntimeError
35
+ def initialize(message = "Database error", code: :database_error, context: {}, original_error: nil)
36
+ super(message, code: code, context: context, original_error: original_error)
37
+ end
35
38
  end
36
39
  end
37
40
  end
@@ -20,7 +20,10 @@ module BetterService
20
20
  #
21
21
  # MyService.new(user, params: {}).call
22
22
  # # => raises ExecutionError wrapping SocketError
23
- class ExecutionError < RuntimeError
23
+ class ExecutionError < BetterService::Errors::Runtime::RuntimeError
24
+ def initialize(message = "Execution failed", code: :execution_error, context: {}, original_error: nil)
25
+ super(message, code: code, context: context, original_error: original_error)
26
+ end
24
27
  end
25
28
  end
26
29
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ module Errors
5
+ module Runtime
6
+ # InvalidResultError - Raised when a service does not return BetterService::Result
7
+ #
8
+ # All services MUST return a BetterService::Result object. This error is raised
9
+ # when a service returns a Hash, Array (tuple), or any other type instead.
10
+ #
11
+ # @example
12
+ # raise BetterService::Errors::Runtime::InvalidResultError.new(
13
+ # "Service MyService must return BetterService::Result, got Hash",
14
+ # context: { service: "MyService", result_class: "Hash" }
15
+ # )
16
+ class InvalidResultError < RuntimeError
17
+ def initialize(message = nil, code: :invalid_result, context: {}, original_error: nil)
18
+ super(
19
+ message || "Service must return BetterService::Result",
20
+ code: code,
21
+ context: context,
22
+ original_error: original_error
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -31,7 +31,10 @@ module BetterService
31
31
  #
32
32
  # MyService.new(user, params: { user_id: 99999 }).call
33
33
  # # => raises ResourceNotFoundError
34
- class ResourceNotFoundError < RuntimeError
34
+ class ResourceNotFoundError < BetterService::Errors::Runtime::RuntimeError
35
+ def initialize(message = "Resource not found", code: :resource_not_found, context: {}, original_error: nil)
36
+ super(message, code: code, context: context, original_error: original_error)
37
+ end
35
38
  end
36
39
  end
37
40
  end
@@ -35,7 +35,10 @@ module BetterService
35
35
  # validation_errors: e.context[:validation_errors]
36
36
  # }, status: :unprocessable_entity
37
37
  # end
38
- class ValidationError < RuntimeError
38
+ class ValidationError < BetterService::Errors::Runtime::RuntimeError
39
+ def initialize(message = "Validation failed", code: :validation_failed, context: {}, original_error: nil)
40
+ super(message, code: code, context: context, original_error: original_error)
41
+ end
39
42
  end
40
43
  end
41
44
  end
@@ -249,7 +249,7 @@ module BetterService
249
249
  # @param per_page [Integer] Records per page
250
250
  # @return [ActiveRecord::Relation] Paginated scope
251
251
  def paginate(scope, page:, per_page:)
252
- offset_value = ([page.to_i, 1].max - 1) * per_page.to_i
252
+ offset_value = ([ page.to_i, 1 ].max - 1) * per_page.to_i
253
253
  scope.offset(offset_value).limit(per_page)
254
254
  end
255
255
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ # Result wrapper per le risposte dei service
5
+ #
6
+ # Fornisce un modo standardizzato per restituire sia la risorsa che i metadata.
7
+ # BetterController può automaticamente unwrappare gli oggetti Result.
8
+ #
9
+ # @example Caso di successo
10
+ # BetterService::Result.new(user, meta: { message: "Created" })
11
+ #
12
+ # @example Caso di fallimento
13
+ # BetterService::Result.new(user, meta: { success: false, message: "Validation failed" })
14
+ #
15
+ class Result
16
+ attr_reader :resource, :meta
17
+
18
+ # @param resource [Object] L'oggetto risorsa (model, collection, etc.)
19
+ # @param meta [Hash] Hash di metadata, deve contenere :success key (default: true)
20
+ def initialize(resource, meta: {})
21
+ @resource = resource
22
+ @meta = meta.is_a?(Hash) ? meta.reverse_merge(success: true) : { success: true }
23
+ end
24
+
25
+ # @return [Boolean] true se l'operazione è riuscita
26
+ def success?
27
+ meta[:success] == true
28
+ end
29
+
30
+ # @return [Boolean] true se l'operazione è fallita
31
+ def failure?
32
+ !success?
33
+ end
34
+
35
+ # @return [String, nil] Il messaggio dal meta
36
+ def message
37
+ meta[:message]
38
+ end
39
+
40
+ # @return [Symbol, nil] L'azione eseguita
41
+ def action
42
+ meta[:action]
43
+ end
44
+
45
+ # @return [Hash, nil] Gli errori di validazione
46
+ def validation_errors
47
+ meta[:validation_errors]
48
+ end
49
+
50
+ # @return [Array<String>, nil] I messaggi di errore completi
51
+ def full_messages
52
+ meta[:full_messages]
53
+ end
54
+
55
+ # @return [ActiveModel::Errors, nil] Errori dalla risorsa se disponibili
56
+ def errors
57
+ resource.respond_to?(:errors) ? resource.errors : nil
58
+ end
59
+
60
+ # Supporta destructuring: resource, meta = result
61
+ # @return [Array] [resource, meta]
62
+ def to_ary
63
+ [ resource, meta ]
64
+ end
65
+
66
+ # Alias per compatibilità con destructuring
67
+ alias_method :deconstruct, :to_ary
68
+
69
+ # @return [Hash] Rappresentazione completa
70
+ def to_h
71
+ { resource: resource, meta: meta }
72
+ end
73
+
74
+ # Accesso Hash-like per compatibilità con BetterController
75
+ # @param key [Symbol] La chiave da accedere
76
+ # @return [Object, nil] Il valore associato alla chiave
77
+ def [](key)
78
+ case key
79
+ when :resource then resource
80
+ when :meta then meta
81
+ when :success then success?
82
+ when :message then message
83
+ when :action then action
84
+ else
85
+ meta[key]
86
+ end
87
+ end
88
+
89
+ # Accesso nested Hash-like (dig)
90
+ # @param keys [Array<Symbol>] Le chiavi per l'accesso nested
91
+ # @return [Object, nil] Il valore nested
92
+ def dig(*keys)
93
+ return nil if keys.empty?
94
+
95
+ value = self[keys.first]
96
+ return value if keys.size == 1
97
+ return nil unless value.respond_to?(:dig)
98
+
99
+ value.dig(*keys[1..])
100
+ end
101
+
102
+ # Verifica esistenza chiave
103
+ # @param key [Symbol] La chiave da verificare
104
+ # @return [Boolean]
105
+ def key?(key)
106
+ %i[resource meta success message action].include?(key) || meta.key?(key)
107
+ end
108
+ alias_method :has_key?, :key?
109
+ end
110
+ end