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.
- checksums.yaml +4 -4
- data/.github/config/rubocop_linter_action.yml +4 -4
- data/.github/workflows/ci.yml +12 -12
- data/.gitignore +1 -0
- data/.rubocop.yml +83 -7
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +139 -0
- data/Gemfile +16 -11
- data/Gemfile.lock +53 -27
- data/README.md +84 -21
- data/docs/arguments.md +290 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +476 -0
- data/docs/concepts.md +80 -0
- data/docs/configuration.md +204 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +280 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +158 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +134 -0
- data/docs/readme.md +101 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +285 -0
- data/docs/ruby-lsp.md +133 -0
- data/docs/service-rendering.md +222 -0
- data/docs/steps.md +391 -0
- data/docs/summary.md +21 -0
- data/docs/testing.md +549 -0
- data/lib/generators/light_services/install/USAGE +15 -0
- data/lib/generators/light_services/install/install_generator.rb +41 -0
- data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/light_services/service/USAGE +21 -0
- data/lib/generators/light_services/service/service_generator.rb +68 -0
- data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
- data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
- data/lib/light/services/base.rb +134 -122
- data/lib/light/services/base_with_context.rb +23 -1
- data/lib/light/services/callbacks.rb +157 -0
- data/lib/light/services/collection.rb +145 -0
- data/lib/light/services/concerns/execution.rb +79 -0
- data/lib/light/services/concerns/parent_service.rb +34 -0
- data/lib/light/services/concerns/state_management.rb +30 -0
- data/lib/light/services/config.rb +82 -16
- data/lib/light/services/constants.rb +100 -0
- data/lib/light/services/dsl/arguments_dsl.rb +85 -0
- data/lib/light/services/dsl/outputs_dsl.rb +81 -0
- data/lib/light/services/dsl/steps_dsl.rb +205 -0
- data/lib/light/services/dsl/validation.rb +162 -0
- data/lib/light/services/exceptions.rb +25 -2
- data/lib/light/services/message.rb +28 -3
- data/lib/light/services/messages.rb +92 -32
- data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
- data/lib/light/services/rspec/matchers/define_output.rb +147 -0
- data/lib/light/services/rspec/matchers/define_step.rb +225 -0
- data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
- data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
- data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
- data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
- data/lib/light/services/rspec.rb +15 -0
- data/lib/light/services/rubocop/cop/light_services/argument_type_required.rb +52 -0
- data/lib/light/services/rubocop/cop/light_services/condition_method_exists.rb +173 -0
- data/lib/light/services/rubocop/cop/light_services/deprecated_methods.rb +113 -0
- data/lib/light/services/rubocop/cop/light_services/dsl_order.rb +176 -0
- data/lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb +102 -0
- data/lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb +66 -0
- data/lib/light/services/rubocop/cop/light_services/output_type_required.rb +52 -0
- data/lib/light/services/rubocop/cop/light_services/step_method_exists.rb +109 -0
- data/lib/light/services/rubocop.rb +12 -0
- data/lib/light/services/settings/field.rb +114 -0
- data/lib/light/services/settings/step.rb +53 -20
- data/lib/light/services/utils.rb +38 -0
- data/lib/light/services/version.rb +1 -1
- data/lib/light/services.rb +2 -0
- data/lib/ruby_lsp/light_services/addon.rb +36 -0
- data/lib/ruby_lsp/light_services/definition.rb +132 -0
- data/lib/ruby_lsp/light_services/indexing_enhancement.rb +263 -0
- data/light-services.gemspec +6 -8
- metadata +68 -26
- data/lib/light/services/class_based_collection/base.rb +0 -86
- data/lib/light/services/class_based_collection/mount.rb +0 -33
- data/lib/light/services/collection/arguments.rb +0 -34
- data/lib/light/services/collection/base.rb +0 -59
- data/lib/light/services/collection/outputs.rb +0 -16
- data/lib/light/services/settings/argument.rb +0 -68
- 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
|
-
|
|
8
|
-
|
|
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
|
-
#
|
|
8
|
-
attr_reader :key
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
end
|
|
141
|
+
def valid_error_text?(text)
|
|
142
|
+
return false unless text.is_a?(String)
|
|
84
143
|
|
|
85
|
-
|
|
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
|