light-services 2.2.1 → 3.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/.github/config/rubocop_linter_action.yml +4 -4
  3. data/.github/workflows/ci.yml +12 -12
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +83 -7
  6. data/CHANGELOG.md +38 -0
  7. data/CLAUDE.md +139 -0
  8. data/Gemfile +16 -11
  9. data/Gemfile.lock +53 -27
  10. data/README.md +84 -21
  11. data/docs/arguments.md +290 -0
  12. data/docs/best-practices.md +153 -0
  13. data/docs/callbacks.md +476 -0
  14. data/docs/concepts.md +80 -0
  15. data/docs/configuration.md +204 -0
  16. data/docs/context.md +128 -0
  17. data/docs/crud.md +525 -0
  18. data/docs/errors.md +280 -0
  19. data/docs/generators.md +250 -0
  20. data/docs/outputs.md +158 -0
  21. data/docs/pundit-authorization.md +320 -0
  22. data/docs/quickstart.md +134 -0
  23. data/docs/readme.md +101 -0
  24. data/docs/recipes.md +14 -0
  25. data/docs/rubocop.md +285 -0
  26. data/docs/ruby-lsp.md +133 -0
  27. data/docs/service-rendering.md +222 -0
  28. data/docs/steps.md +391 -0
  29. data/docs/summary.md +21 -0
  30. data/docs/testing.md +549 -0
  31. data/lib/generators/light_services/install/USAGE +15 -0
  32. data/lib/generators/light_services/install/install_generator.rb +41 -0
  33. data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
  34. data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
  35. data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
  36. data/lib/generators/light_services/service/USAGE +21 -0
  37. data/lib/generators/light_services/service/service_generator.rb +68 -0
  38. data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
  39. data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
  40. data/lib/light/services/base.rb +134 -122
  41. data/lib/light/services/base_with_context.rb +23 -1
  42. data/lib/light/services/callbacks.rb +157 -0
  43. data/lib/light/services/collection.rb +145 -0
  44. data/lib/light/services/concerns/execution.rb +79 -0
  45. data/lib/light/services/concerns/parent_service.rb +34 -0
  46. data/lib/light/services/concerns/state_management.rb +30 -0
  47. data/lib/light/services/config.rb +82 -16
  48. data/lib/light/services/constants.rb +100 -0
  49. data/lib/light/services/dsl/arguments_dsl.rb +85 -0
  50. data/lib/light/services/dsl/outputs_dsl.rb +81 -0
  51. data/lib/light/services/dsl/steps_dsl.rb +205 -0
  52. data/lib/light/services/dsl/validation.rb +162 -0
  53. data/lib/light/services/exceptions.rb +25 -2
  54. data/lib/light/services/message.rb +28 -3
  55. data/lib/light/services/messages.rb +92 -32
  56. data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
  57. data/lib/light/services/rspec/matchers/define_output.rb +147 -0
  58. data/lib/light/services/rspec/matchers/define_step.rb +225 -0
  59. data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
  60. data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
  61. data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
  62. data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
  63. data/lib/light/services/rspec.rb +15 -0
  64. data/lib/light/services/rubocop/cop/light_services/argument_type_required.rb +52 -0
  65. data/lib/light/services/rubocop/cop/light_services/condition_method_exists.rb +173 -0
  66. data/lib/light/services/rubocop/cop/light_services/deprecated_methods.rb +113 -0
  67. data/lib/light/services/rubocop/cop/light_services/dsl_order.rb +176 -0
  68. data/lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb +102 -0
  69. data/lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb +66 -0
  70. data/lib/light/services/rubocop/cop/light_services/output_type_required.rb +52 -0
  71. data/lib/light/services/rubocop/cop/light_services/step_method_exists.rb +109 -0
  72. data/lib/light/services/rubocop.rb +12 -0
  73. data/lib/light/services/settings/field.rb +114 -0
  74. data/lib/light/services/settings/step.rb +53 -20
  75. data/lib/light/services/utils.rb +38 -0
  76. data/lib/light/services/version.rb +1 -1
  77. data/lib/light/services.rb +2 -0
  78. data/lib/ruby_lsp/light_services/addon.rb +36 -0
  79. data/lib/ruby_lsp/light_services/definition.rb +132 -0
  80. data/lib/ruby_lsp/light_services/indexing_enhancement.rb +263 -0
  81. data/light-services.gemspec +6 -8
  82. metadata +68 -26
  83. data/lib/light/services/class_based_collection/base.rb +0 -86
  84. data/lib/light/services/class_based_collection/mount.rb +0 -33
  85. data/lib/light/services/collection/arguments.rb +0 -34
  86. data/lib/light/services/collection/base.rb +0 -59
  87. data/lib/light/services/collection/outputs.rb +0 -16
  88. data/lib/light/services/settings/argument.rb +0 -68
  89. data/lib/light/services/settings/output.rb +0 -34
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+ require_relative "validation"
5
+
6
+ module Light
7
+ module Services
8
+ module Dsl
9
+ # DSL for defining and managing service steps
10
+ module StepsDsl
11
+ def self.included(base)
12
+ base.extend(ClassMethods)
13
+ end
14
+
15
+ module ClassMethods
16
+ # Define a step for the service
17
+ #
18
+ # @param name [Symbol] the step name (must correspond to a private method)
19
+ # @param opts [Hash] options for configuring the step
20
+ # @option opts [Symbol, Proc] :if Condition to determine if step should run
21
+ # @option opts [Symbol, Proc] :unless Condition to skip step (returns truthy to skip)
22
+ # @option opts [Boolean] :always (false) Run step even after errors/warnings
23
+ # @option opts [Symbol] :before Insert this step before the specified step
24
+ # @option opts [Symbol] :after Insert this step after the specified step
25
+ #
26
+ # @example Define a simple step
27
+ # step :validate_input
28
+ #
29
+ # @example Define a conditional step
30
+ # step :send_notification, if: :should_notify?
31
+ # step :skip_validation, unless: :production?
32
+ #
33
+ # @example Define a step that always runs
34
+ # step :cleanup, always: true
35
+ #
36
+ # @example Define step ordering
37
+ # step :log_start, before: :validate_input
38
+ # step :log_end, after: :process_data
39
+ #
40
+ # @example Define a step with proc condition
41
+ # step :premium_feature, if: -> { user.premium? && feature_enabled? }
42
+ def step(name, opts = {}) # rubocop:disable Metrics/MethodLength
43
+ Validation.validate_symbol_name!(name, :step, self)
44
+ Validation.validate_reserved_name!(name, :step, self)
45
+ Validation.validate_name_conflicts!(name, :step, self)
46
+ validate_step_opts!(name, opts)
47
+
48
+ # Build current steps to check for duplicates and find insertion targets
49
+ current = steps
50
+ if current.key?(name)
51
+ raise Light::Services::Error,
52
+ "Step `#{name}` is already defined in service #{self}. Each step must have a unique name."
53
+ end
54
+
55
+ if (target = opts[:before] || opts[:after]) && !current.key?(target)
56
+ available = current.keys.join(", ")
57
+ raise Light::Services::Error,
58
+ "Cannot find target step `#{target}` in service #{self}. Available steps: [#{available}]"
59
+ end
60
+
61
+ step_obj = Settings::Step.new(name, self, opts)
62
+
63
+ if opts[:before] || opts[:after]
64
+ step_operations << { action: :insert, name: name, step: step_obj, before: opts[:before],
65
+ after: opts[:after], }
66
+ else
67
+ step_operations << { action: :add, name: name, step: step_obj }
68
+ end
69
+
70
+ # Clear memoized steps since we're modifying them
71
+ @steps = nil
72
+ end
73
+
74
+ # Remove a step from the service
75
+ #
76
+ # @param name [Symbol] the step name to remove
77
+ def remove_step(name)
78
+ step_operations << { action: :remove, name: name }
79
+
80
+ # Clear memoized steps since we're modifying them
81
+ @steps = nil
82
+ end
83
+
84
+ # Get all steps including inherited ones
85
+ #
86
+ # @return [Hash] all steps defined for this service
87
+ def steps
88
+ @steps ||= build_steps
89
+ end
90
+
91
+ # Get the list of step operations to be applied
92
+ #
93
+ # @return [Array] list of operations
94
+ def step_operations
95
+ @step_operations ||= []
96
+ end
97
+
98
+ # Validate that the service has steps defined
99
+ # Called before executing the service
100
+ #
101
+ # @raise [NoStepsError] if no steps are defined
102
+ def validate_steps!
103
+ return unless steps.empty?
104
+
105
+ raise Light::Services::NoStepsError,
106
+ "Service #{self} has no steps defined. Define at least one step or implement a `run` method."
107
+ end
108
+
109
+ private
110
+
111
+ # Validate step options to ensure they are valid
112
+ #
113
+ # @param name [Symbol] the step name
114
+ # @param opts [Hash] the step options
115
+ def validate_step_opts!(name, opts)
116
+ return unless opts[:before] && opts[:after]
117
+
118
+ raise Light::Services::Error, "You cannot specify `before` and `after` " \
119
+ "for step `#{name}` in service #{self} at the same time"
120
+ end
121
+
122
+ # Build steps by applying operations to inherited steps
123
+ #
124
+ # @return [Hash] the final steps hash
125
+ def build_steps
126
+ # Start with inherited steps
127
+ result = inherit_steps
128
+
129
+ # Apply operations in order
130
+ step_operations.each { |op| apply_step_operation(result, op) }
131
+
132
+ # If no steps defined, check for `run` method as fallback
133
+ result[:run] = Settings::Step.new(:run, self, {}) if result.empty? && instance_method_defined?(:run)
134
+
135
+ result
136
+ end
137
+
138
+ # Check if an instance method is defined in this class or its ancestors
139
+ # (excluding Light::Services::Base and its modules)
140
+ #
141
+ # @param method_name [Symbol] the method name to check
142
+ # @return [Boolean] true if the method is defined
143
+ def instance_method_defined?(method_name)
144
+ # Check if method exists and is not from base service classes
145
+ return false unless method_defined?(method_name) || private_method_defined?(method_name)
146
+
147
+ # Get the method owner to ensure it's defined in user's service class
148
+ owner = instance_method(method_name).owner
149
+
150
+ # Method should be defined in a class that inherits from Base,
151
+ # not in Base itself or its included modules
152
+ !owner.to_s.start_with?("Light::Services")
153
+ end
154
+
155
+ # Inherit steps from parent class
156
+ #
157
+ # @return [Hash] inherited steps
158
+ def inherit_steps
159
+ superclass.respond_to?(:steps) ? superclass.steps.dup : {}
160
+ end
161
+
162
+ # Apply a single step operation to the steps hash
163
+ #
164
+ # @param steps [Hash] the steps hash
165
+ # @param operation [Hash] the operation to apply
166
+ def apply_step_operation(steps, operation)
167
+ case operation[:action]
168
+ when :add
169
+ steps[operation[:name]] = operation[:step]
170
+ when :remove
171
+ steps.delete(operation[:name])
172
+ when :insert
173
+ insert_step(steps, operation)
174
+ end
175
+ end
176
+
177
+ # Insert a step before or after a target step
178
+ #
179
+ # @param steps [Hash] the steps hash
180
+ # @param operation [Hash] the insert operation details
181
+ def insert_step(steps, operation)
182
+ target = operation[:before] || operation[:after]
183
+ keys = steps.keys
184
+ index = keys.index(target)
185
+ return unless index
186
+
187
+ # More efficient insertion using ordered hash reconstruction
188
+ new_steps = {}
189
+
190
+ keys.each_with_index do |key, i|
191
+ # Insert before target
192
+ new_steps[operation[:name]] = operation[:step] if operation[:before] && i == index
193
+ new_steps[key] = steps[key]
194
+
195
+ # Insert after target
196
+ new_steps[operation[:name]] = operation[:step] if operation[:after] && i == index
197
+ end
198
+
199
+ steps.replace(new_steps)
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+
5
+ module Light
6
+ module Services
7
+ module Dsl
8
+ # Shared validation logic for DSL modules
9
+ module Validation
10
+ # Validate that the name is a symbol
11
+ #
12
+ # @param name [Object] the name to validate
13
+ # @param field_type [Symbol] the type of field (:argument, :output, :step)
14
+ # @param service_class [Class] the service class for error messages
15
+ def self.validate_symbol_name!(name, field_type, service_class)
16
+ return if name.is_a?(Symbol)
17
+
18
+ raise Light::Services::InvalidNameError,
19
+ "#{field_type.to_s.capitalize} name must be a Symbol, " \
20
+ "got #{name.class} (#{name.inspect}) in #{service_class}"
21
+ end
22
+
23
+ # Validate that the name is not a reserved word
24
+ #
25
+ # @param name [Symbol] the name to validate
26
+ # @param field_type [Symbol] the type of field (:argument, :output, :step)
27
+ # @param service_class [Class] the service class for error messages
28
+ def self.validate_reserved_name!(name, field_type, service_class)
29
+ return unless ReservedNames::ALL.include?(name.to_sym)
30
+
31
+ raise Light::Services::ReservedNameError,
32
+ "Cannot use `#{name}` as #{field_type} name in #{service_class} - " \
33
+ "it is a reserved word that conflicts with gem methods"
34
+ end
35
+
36
+ # Validate that the name doesn't conflict with other defined names
37
+ #
38
+ # @param name [Symbol] the name to validate
39
+ # @param field_type [Symbol] the type of field being defined (:argument, :output, :step)
40
+ # @param service_class [Class] the service class to check for conflicts
41
+ def self.validate_name_conflicts!(name, field_type, service_class)
42
+ name_sym = name.to_sym
43
+
44
+ case field_type
45
+ when :argument
46
+ validate_argument_conflicts!(name_sym, service_class)
47
+ when :output
48
+ validate_output_conflicts!(name_sym, service_class)
49
+ when :step
50
+ validate_step_conflicts!(name_sym, service_class)
51
+ end
52
+ end
53
+
54
+ # Validate argument name doesn't conflict with outputs or steps
55
+ def self.validate_argument_conflicts!(name_sym, service_class)
56
+ # Check against existing outputs
57
+ if has_output?(name_sym, service_class)
58
+ raise Light::Services::ReservedNameError,
59
+ "Cannot use `#{name_sym}` as argument name in #{service_class} - " \
60
+ "it is already defined as an output"
61
+ end
62
+
63
+ # Check against existing steps
64
+ if has_step?(name_sym, service_class)
65
+ raise Light::Services::ReservedNameError,
66
+ "Cannot use `#{name_sym}` as argument name in #{service_class} - " \
67
+ "it is already defined as a step"
68
+ end
69
+ end
70
+
71
+ # Validate output name doesn't conflict with arguments or steps
72
+ def self.validate_output_conflicts!(name_sym, service_class)
73
+ # Check against existing arguments
74
+ if has_argument?(name_sym, service_class)
75
+ raise Light::Services::ReservedNameError,
76
+ "Cannot use `#{name_sym}` as output name in #{service_class} - " \
77
+ "it is already defined as an argument"
78
+ end
79
+
80
+ # Check against existing steps
81
+ if has_step?(name_sym, service_class)
82
+ raise Light::Services::ReservedNameError,
83
+ "Cannot use `#{name_sym}` as output name in #{service_class} - " \
84
+ "it is already defined as a step"
85
+ end
86
+ end
87
+
88
+ # Validate step name doesn't conflict with arguments or outputs
89
+ def self.validate_step_conflicts!(name_sym, service_class)
90
+ # Check against existing arguments
91
+ if has_argument?(name_sym, service_class)
92
+ raise Light::Services::ReservedNameError,
93
+ "Cannot use `#{name_sym}` as step name in #{service_class} - " \
94
+ "it is already defined as an argument"
95
+ end
96
+
97
+ # Check against existing outputs
98
+ if has_output?(name_sym, service_class)
99
+ raise Light::Services::ReservedNameError,
100
+ "Cannot use `#{name_sym}` as step name in #{service_class} - " \
101
+ "it is already defined as an output"
102
+ end
103
+ end
104
+
105
+ # Check if a name is already defined as an argument
106
+ def self.has_argument?(name_sym, service_class)
107
+ # Check own_arguments (current class)
108
+ (service_class.respond_to?(:own_arguments) && service_class.own_arguments.key?(name_sym)) ||
109
+ # Check inherited arguments
110
+ (service_class.superclass.respond_to?(:arguments) && service_class.superclass.arguments.key?(name_sym))
111
+ end
112
+
113
+ # Check if a name is already defined as an output
114
+ def self.has_output?(name_sym, service_class)
115
+ # Check own_outputs (current class)
116
+ (service_class.respond_to?(:own_outputs) && service_class.own_outputs.key?(name_sym)) ||
117
+ # Check inherited outputs
118
+ (service_class.superclass.respond_to?(:outputs) && service_class.superclass.outputs.key?(name_sym))
119
+ end
120
+
121
+ # Check if a name is already defined as a step
122
+ def self.has_step?(name_sym, service_class)
123
+ # Check step_operations (current class) for non-removed steps
124
+ (service_class.respond_to?(:step_operations) &&
125
+ service_class.step_operations.any? { |op| op[:name] == name_sym && op[:action] != :remove }) ||
126
+ # Check inherited steps
127
+ (service_class.superclass.respond_to?(:steps) && service_class.superclass.steps.key?(name_sym))
128
+ end
129
+
130
+ # Validate that the type option is provided when require_type is enabled
131
+ #
132
+ # @param name [Symbol] the field name
133
+ # @param field_type [Symbol] the type of field (:argument, :output)
134
+ # @param service_class [Class] the service class for error messages
135
+ # @param opts [Hash] the options hash to check for type
136
+ def self.validate_type_required!(name, field_type, service_class, opts)
137
+ return if opts.key?(:type)
138
+ return unless require_type_enabled?(service_class)
139
+
140
+ raise Light::Services::MissingTypeError,
141
+ "#{field_type.to_s.capitalize} `#{name}` in #{service_class} must have a type specified " \
142
+ "(require_type is enabled)"
143
+ end
144
+
145
+ # Check if require_type is enabled for the service class
146
+ def self.require_type_enabled?(service_class)
147
+ # Check class-level config in the inheritance chain, then fall back to global config
148
+ klass = service_class
149
+ while klass.respond_to?(:class_config)
150
+ class_config = klass.class_config
151
+
152
+ return class_config[:require_type] if class_config&.key?(:require_type)
153
+
154
+ klass = klass.superclass
155
+ end
156
+
157
+ Light::Services.config.require_type
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -2,9 +2,32 @@
2
2
 
3
3
  module Light
4
4
  module Services
5
+ # Base exception class for all Light::Services errors.
5
6
  class Error < StandardError; end
7
+
8
+ # Raised when an argument or output value doesn't match the expected type.
6
9
  class ArgTypeError < Error; end
7
- class NoStepError < Error; end
8
- class TwoConditions < Error; end
10
+
11
+ # Raised when using a reserved name for an argument, output, or step.
12
+ class ReservedNameError < Error; end
13
+
14
+ # Raised when a name is invalid (e.g., not a Symbol).
15
+ class InvalidNameError < Error; end
16
+
17
+ # Raised when a service has no steps defined and no run method.
18
+ class NoStepsError < Error; end
19
+
20
+ # Raised when type is required but not specified for an argument or output.
21
+ class MissingTypeError < Error; end
22
+
23
+ # Control flow exception for stop_immediately!
24
+ # Not an error - used to halt execution gracefully.
25
+ class StopExecution < StandardError; end
26
+
27
+ # @deprecated Use {Error} instead
28
+ NoStepError = Error
29
+
30
+ # @deprecated Use {Error} instead
31
+ TwoConditions = Error
9
32
  end
10
33
  end
@@ -1,26 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This class stores errors and warnings
4
3
  module Light
5
4
  module Services
5
+ # Represents a single error or warning message.
6
+ #
7
+ # @example Creating a message
8
+ # message = Message.new(:name, "can't be blank", break: true)
9
+ # message.key # => :name
10
+ # message.text # => "can't be blank"
11
+ # message.break? # => true
6
12
  class Message
7
- # Getters
8
- attr_reader :key, :text
13
+ # @return [Symbol] the key/field this message belongs to
14
+ attr_reader :key
9
15
 
16
+ # @return [String] the message text
17
+ attr_reader :text
18
+
19
+ # Create a new message.
20
+ #
21
+ # @param key [Symbol] the key/field this message belongs to
22
+ # @param text [String] the message text
23
+ # @param opts [Hash] additional options
24
+ # @option opts [Boolean] :break whether to stop step execution
25
+ # @option opts [Boolean] :rollback whether to rollback the transaction
10
26
  def initialize(key, text, opts = {})
11
27
  @key = key
12
28
  @text = text
13
29
  @opts = opts
14
30
  end
15
31
 
32
+ # Check if this message should stop step execution.
33
+ #
34
+ # @return [Boolean] true if break option was set
16
35
  def break?
17
36
  @opts[:break]
18
37
  end
19
38
 
39
+ # Check if this message should trigger a transaction rollback.
40
+ #
41
+ # @return [Boolean] true if rollback option was set
20
42
  def rollback?
21
43
  @opts[:rollback]
22
44
  end
23
45
 
46
+ # Return the message text.
47
+ #
48
+ # @return [String] the message text
24
49
  def to_s
25
50
  text
26
51
  end
@@ -1,23 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This class stores errors and warnings
4
3
  module Light
5
4
  module Services
5
+ # Collection of error or warning messages, organized by key.
6
+ #
7
+ # @example Adding and accessing errors
8
+ # errors.add(:name, "can't be blank")
9
+ # errors.add(:email, "is invalid")
10
+ # errors[:name] # => [#<Message key: :name, text: "can't be blank">]
11
+ # errors.to_h # => { name: ["can't be blank"], email: ["is invalid"] }
6
12
  class Messages
13
+ extend Forwardable
14
+
15
+ # @!method [](key)
16
+ # Get messages for a specific key.
17
+ # @param key [Symbol] the key to look up
18
+ # @return [Array<Message>, nil] array of messages or nil
19
+
20
+ # @!method any?
21
+ # Check if there are any messages.
22
+ # @return [Boolean] true if messages exist
23
+
24
+ # @!method empty?
25
+ # Check if the collection is empty.
26
+ # @return [Boolean] true if no messages
27
+
28
+ # @!method size
29
+ # Get number of keys with messages.
30
+ # @return [Integer] number of keys
31
+
32
+ # @!method keys
33
+ # Get all keys with messages.
34
+ # @return [Array<Symbol>] array of keys
35
+
36
+ # @!method key?(key)
37
+ # Check if a key has messages.
38
+ # @param key [Symbol] the key to check
39
+ # @return [Boolean] true if key has messages
40
+ def_delegators :@messages, :[], :any?, :empty?, :size, :keys, :values, :each, :each_with_index, :key?
41
+ alias has_key? key?
42
+
43
+ # Initialize a new messages collection.
44
+ #
45
+ # @param config [Hash] configuration options
46
+ # @option config [Boolean] :break_on_add stop execution when message added
47
+ # @option config [Boolean] :raise_on_add raise exception when message added
48
+ # @option config [Boolean] :rollback_on_add rollback transaction when message added
7
49
  def initialize(config)
8
50
  @break = false
9
51
  @config = config
10
52
  @messages = {}
11
53
  end
12
54
 
55
+ # Get total count of all messages across all keys.
56
+ #
57
+ # @return [Integer] total number of messages
58
+ def count
59
+ @messages.values.sum(&:size)
60
+ end
61
+
62
+ # Add a message to the collection.
63
+ #
64
+ # @param key [Symbol] the key/field for this message
65
+ # @param texts [String, Array<String>, Message] the message text(s) to add
66
+ # @param opts [Hash] additional options
67
+ # @option opts [Boolean] :break override break behavior for this message
68
+ # @option opts [Boolean] :rollback override rollback behavior for this message
69
+ # @return [void]
70
+ # @raise [Error] if text is nil or empty
71
+ #
72
+ # @example Add a single error
73
+ # errors.add(:name, "can't be blank")
74
+ #
75
+ # @example Add multiple errors
76
+ # errors.add(:email, ["is invalid", "is already taken"])
13
77
  def add(key, texts, opts = {})
14
- raise Light::Services::Error, "Error text can't be blank" if !texts || texts.blank?
78
+ raise Light::Services::Error, "Error must be a non-empty string" unless texts
15
79
 
16
80
  message = nil
17
81
 
18
82
  [*texts].each do |text|
19
83
  message = text.is_a?(Message) ? text : Message.new(key, text, opts)
20
84
 
85
+ raise Light::Services::Error, "Error must be a non-empty string" unless valid_error_text?(message.text)
86
+
21
87
  @messages[key] ||= []
22
88
  @messages[key] << message
23
89
  end
@@ -27,10 +93,25 @@ module Light
27
93
  rollback!(opts.key?(:rollback) ? opts[:rollback] : message.rollback?) if !opts.key?(:last) || opts[:last]
28
94
  end
29
95
 
96
+ # Check if step execution should stop.
97
+ #
98
+ # @return [Boolean] true if a message triggered a break
30
99
  def break?
31
100
  @break
32
101
  end
33
102
 
103
+ # Copy messages from another source.
104
+ #
105
+ # @param entity [ActiveRecord::Base, Base, Hash, #each] source to copy from
106
+ # @param opts [Hash] options to pass to each added message
107
+ # @return [void]
108
+ # @raise [Error] if entity type is not supported
109
+ #
110
+ # @example Copy from ActiveRecord model
111
+ # errors.copy_from(user) # copies user.errors
112
+ #
113
+ # @example Copy from another service
114
+ # errors.copy_from(child_service)
34
115
  def copy_from(entity, opts = {})
35
116
  if defined?(ActiveRecord::Base) && entity.is_a?(ActiveRecord::Base)
36
117
  copy_from(entity.errors.messages, opts)
@@ -46,43 +127,22 @@ module Light
46
127
  raise Light::Services::Error, "Don't know how to import errors from #{entity}"
47
128
  end
48
129
  end
130
+ alias from_record copy_from
49
131
 
50
- def copy_to(entity)
51
- if (defined?(ActiveRecord::Base) && entity.is_a?(ActiveRecord::Base)) || entity.is_a?(Light::Services::Base)
52
- each do |key, messages|
53
- messages.each do |message|
54
- entity.errors.add(key, message.to_s)
55
- end
56
- end
57
- elsif entity.is_a?(Hash)
58
- each do |key, messages|
59
- entity[key] ||= []
60
- entity[key] += messages.map(&:to_s)
61
- end
62
- else
63
- raise Light::Services::Error, "Don't know how to export errors to #{entity}"
64
- end
65
-
66
- entity
67
- end
68
-
132
+ # Convert messages to a hash with string values.
133
+ #
134
+ # @return [Hash{Symbol => Array<String>}] messages as hash
69
135
  def to_h
70
136
  @messages.to_h.transform_values { |value| value.map(&:to_s) }
71
137
  end
72
138
 
73
- def method_missing(method, *args, &block)
74
- if @messages.respond_to?(method)
75
- @messages.public_send(method, *args, &block)
76
- else
77
- super
78
- end
79
- end
139
+ private
80
140
 
81
- def respond_to_missing?(method, include_private = false)
82
- @messages.respond_to?(method, include_private) || super
83
- end
141
+ def valid_error_text?(text)
142
+ return false unless text.is_a?(String)
84
143
 
85
- private
144
+ !text.strip.empty?
145
+ end
86
146
 
87
147
  def break!(break_execution)
88
148
  return unless break_execution.nil? ? @config[:break_on_add] : break_execution