light-services 2.2.1 → 3.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 (73) 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 +77 -7
  6. data/CHANGELOG.md +23 -0
  7. data/CLAUDE.md +139 -0
  8. data/Gemfile +16 -11
  9. data/Gemfile.lock +53 -27
  10. data/README.md +76 -13
  11. data/docs/arguments.md +267 -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 +168 -0
  16. data/docs/context.md +128 -0
  17. data/docs/crud.md +525 -0
  18. data/docs/errors.md +250 -0
  19. data/docs/generators.md +250 -0
  20. data/docs/outputs.md +135 -0
  21. data/docs/pundit-authorization.md +320 -0
  22. data/docs/quickstart.md +134 -0
  23. data/docs/readme.md +100 -0
  24. data/docs/recipes.md +14 -0
  25. data/docs/service-rendering.md +222 -0
  26. data/docs/steps.md +337 -0
  27. data/docs/summary.md +19 -0
  28. data/docs/testing.md +549 -0
  29. data/lib/generators/light_services/install/USAGE +15 -0
  30. data/lib/generators/light_services/install/install_generator.rb +41 -0
  31. data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
  32. data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
  33. data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
  34. data/lib/generators/light_services/service/USAGE +21 -0
  35. data/lib/generators/light_services/service/service_generator.rb +68 -0
  36. data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
  37. data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
  38. data/lib/light/services/base.rb +23 -113
  39. data/lib/light/services/callbacks.rb +103 -0
  40. data/lib/light/services/collection.rb +97 -0
  41. data/lib/light/services/concerns/execution.rb +76 -0
  42. data/lib/light/services/concerns/parent_service.rb +34 -0
  43. data/lib/light/services/concerns/state_management.rb +30 -0
  44. data/lib/light/services/config.rb +4 -18
  45. data/lib/light/services/constants.rb +97 -0
  46. data/lib/light/services/dsl/arguments_dsl.rb +84 -0
  47. data/lib/light/services/dsl/outputs_dsl.rb +80 -0
  48. data/lib/light/services/dsl/steps_dsl.rb +205 -0
  49. data/lib/light/services/dsl/validation.rb +132 -0
  50. data/lib/light/services/exceptions.rb +7 -2
  51. data/lib/light/services/messages.rb +19 -31
  52. data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
  53. data/lib/light/services/rspec/matchers/define_output.rb +147 -0
  54. data/lib/light/services/rspec/matchers/define_step.rb +225 -0
  55. data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
  56. data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
  57. data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
  58. data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
  59. data/lib/light/services/rspec.rb +15 -0
  60. data/lib/light/services/settings/field.rb +86 -0
  61. data/lib/light/services/settings/step.rb +31 -16
  62. data/lib/light/services/utils.rb +38 -0
  63. data/lib/light/services/version.rb +1 -1
  64. data/lib/light/services.rb +2 -0
  65. data/light-services.gemspec +6 -8
  66. metadata +54 -26
  67. data/lib/light/services/class_based_collection/base.rb +0 -86
  68. data/lib/light/services/class_based_collection/mount.rb +0 -33
  69. data/lib/light/services/collection/arguments.rb +0 -34
  70. data/lib/light/services/collection/base.rb +0 -59
  71. data/lib/light/services/collection/outputs.rb +0 -16
  72. data/lib/light/services/settings/argument.rb +0 -68
  73. data/lib/light/services/settings/output.rb +0 -34
@@ -4,20 +4,32 @@
4
4
  module Light
5
5
  module Services
6
6
  class Messages
7
+ extend Forwardable
8
+
9
+ def_delegators :@messages, :[], :any?, :empty?, :size, :keys, :values, :each, :each_with_index, :key?
10
+ alias has_key? key?
11
+
7
12
  def initialize(config)
8
13
  @break = false
9
14
  @config = config
10
15
  @messages = {}
11
16
  end
12
17
 
18
+ # Returns total count of all messages across all keys
19
+ def count
20
+ @messages.values.sum(&:size)
21
+ end
22
+
13
23
  def add(key, texts, opts = {})
14
- raise Light::Services::Error, "Error text can't be blank" if !texts || texts.blank?
24
+ raise Light::Services::Error, "Error must be a non-empty string" unless texts
15
25
 
16
26
  message = nil
17
27
 
18
28
  [*texts].each do |text|
19
29
  message = text.is_a?(Message) ? text : Message.new(key, text, opts)
20
30
 
31
+ raise Light::Services::Error, "Error must be a non-empty string" unless valid_error_text?(message.text)
32
+
21
33
  @messages[key] ||= []
22
34
  @messages[key] << message
23
35
  end
@@ -46,43 +58,19 @@ module Light
46
58
  raise Light::Services::Error, "Don't know how to import errors from #{entity}"
47
59
  end
48
60
  end
49
-
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
61
+ alias from_record copy_from
68
62
 
69
63
  def to_h
70
64
  @messages.to_h.transform_values { |value| value.map(&:to_s) }
71
65
  end
72
66
 
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
67
+ private
80
68
 
81
- def respond_to_missing?(method, include_private = false)
82
- @messages.respond_to?(method, include_private) || super
83
- end
69
+ def valid_error_text?(text)
70
+ return false unless text.is_a?(String)
84
71
 
85
- private
72
+ !text.strip.empty?
73
+ end
86
74
 
87
75
  def break!(break_execution)
88
76
  return unless break_execution.nil? ? @config[:break_on_add] : break_execution
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Light
4
+ module Services
5
+ module RSpec
6
+ module Matchers
7
+ # Matcher for testing argument definitions on a service class
8
+ #
9
+ # @example Basic usage
10
+ # expect(MyService).to define_argument(:name)
11
+ #
12
+ # @example With type constraint
13
+ # expect(MyService).to define_argument(:name).with_type(String)
14
+ #
15
+ # @example With optional flag
16
+ # expect(MyService).to define_argument(:email).optional
17
+ #
18
+ # @example With default value
19
+ # expect(MyService).to define_argument(:status).with_default("pending")
20
+ #
21
+ # @example With context flag
22
+ # expect(MyService).to define_argument(:current_user).with_context
23
+ #
24
+ # @example Combined
25
+ # expect(MyService).to define_argument(:count).with_type(Integer).optional.with_default(0)
26
+ def define_argument(name)
27
+ DefineArgumentMatcher.new(name)
28
+ end
29
+
30
+ class DefineArgumentMatcher
31
+ def initialize(name)
32
+ @name = name
33
+ @expected_type = nil
34
+ @expected_optional = nil
35
+ @expected_default = nil
36
+ @check_default = false
37
+ @expected_context = nil
38
+ end
39
+
40
+ def with_type(type)
41
+ @expected_type = type
42
+ self
43
+ end
44
+
45
+ def optional(value = true)
46
+ @expected_optional = value
47
+ self
48
+ end
49
+
50
+ def required
51
+ @expected_optional = false
52
+ self
53
+ end
54
+
55
+ def with_default(default)
56
+ @check_default = true
57
+ @expected_default = default
58
+ self
59
+ end
60
+
61
+ def with_context(value = true)
62
+ @expected_context = value
63
+ self
64
+ end
65
+
66
+ def matches?(service_class)
67
+ @service_class = service_class
68
+ @actual_class = service_class.is_a?(Class) ? service_class : service_class.class
69
+
70
+ return false unless argument_defined?
71
+ return false unless type_matches?
72
+ return false unless optional_matches?
73
+ return false unless default_matches?
74
+ return false unless context_matches?
75
+
76
+ true
77
+ end
78
+
79
+ def failure_message
80
+ return "expected #{@actual_class} to define argument :#{@name}" unless argument_defined?
81
+ return type_failure_message unless type_matches?
82
+ return optional_failure_message unless optional_matches?
83
+ return default_failure_message unless default_matches?
84
+ return context_failure_message unless context_matches?
85
+
86
+ ""
87
+ end
88
+
89
+ def failure_message_when_negated
90
+ "expected #{@actual_class} not to define argument :#{@name}"
91
+ end
92
+
93
+ def description
94
+ desc = "define argument :#{@name}"
95
+ desc += " with type #{@expected_type}" if @expected_type
96
+ desc += " as optional" if @expected_optional == true
97
+ desc += " as required" if @expected_optional == false
98
+ desc += " with default #{@expected_default.inspect}" if @check_default
99
+ desc += " with context" if @expected_context
100
+ desc
101
+ end
102
+
103
+ private
104
+
105
+ def argument_defined?
106
+ @actual_class.respond_to?(:arguments) && @actual_class.arguments.key?(@name)
107
+ end
108
+
109
+ def argument
110
+ @argument ||= @actual_class.arguments[@name]
111
+ end
112
+
113
+ def type_matches?
114
+ return true if @expected_type.nil?
115
+
116
+ # Access the type via instance variable since there's no public getter
117
+ actual_type = argument.instance_variable_get(:@type)
118
+ actual_type == @expected_type
119
+ end
120
+
121
+ def optional_matches?
122
+ return true if @expected_optional.nil?
123
+
124
+ argument.optional == @expected_optional
125
+ end
126
+
127
+ def default_matches?
128
+ return true unless @check_default
129
+
130
+ argument.default_exists && argument.default == @expected_default
131
+ end
132
+
133
+ def context_matches?
134
+ return true if @expected_context.nil?
135
+
136
+ argument.context == @expected_context
137
+ end
138
+
139
+ def type_failure_message
140
+ actual_type = argument.instance_variable_get(:@type)
141
+ "expected #{@actual_class} argument :#{@name} to have type #{@expected_type}, " \
142
+ "but it has type #{actual_type.inspect}"
143
+ end
144
+
145
+ def optional_failure_message
146
+ if @expected_optional
147
+ "expected #{@actual_class} argument :#{@name} to be optional, but it is required"
148
+ else
149
+ "expected #{@actual_class} argument :#{@name} to be required, but it is optional"
150
+ end
151
+ end
152
+
153
+ def default_failure_message
154
+ if argument.default_exists
155
+ "expected #{@actual_class} argument :#{@name} to have default #{@expected_default.inspect}, " \
156
+ "but it has default #{argument.default.inspect}"
157
+ else
158
+ "expected #{@actual_class} argument :#{@name} to have default #{@expected_default.inspect}, " \
159
+ "but no default is defined"
160
+ end
161
+ end
162
+
163
+ def context_failure_message
164
+ if @expected_context
165
+ "expected #{@actual_class} argument :#{@name} to have context flag, but it doesn't"
166
+ else
167
+ "expected #{@actual_class} argument :#{@name} not to have context flag, but it does"
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Light
4
+ module Services
5
+ module RSpec
6
+ module Matchers
7
+ # Matcher for testing output definitions on a service class
8
+ #
9
+ # @example Basic usage
10
+ # expect(MyService).to define_output(:result)
11
+ #
12
+ # @example With type constraint
13
+ # expect(MyService).to define_output(:product).with_type(Product)
14
+ #
15
+ # @example With optional flag
16
+ # expect(MyService).to define_output(:message).optional
17
+ #
18
+ # @example With default value
19
+ # expect(MyService).to define_output(:count).with_default(0)
20
+ #
21
+ # @example Combined
22
+ # expect(MyService).to define_output(:data).with_type(Hash).optional.with_default({})
23
+ def define_output(name)
24
+ DefineOutputMatcher.new(name)
25
+ end
26
+
27
+ class DefineOutputMatcher
28
+ def initialize(name)
29
+ @name = name
30
+ @expected_type = nil
31
+ @expected_optional = nil
32
+ @expected_default = nil
33
+ @check_default = false
34
+ end
35
+
36
+ def with_type(type)
37
+ @expected_type = type
38
+ self
39
+ end
40
+
41
+ def optional(value = true)
42
+ @expected_optional = value
43
+ self
44
+ end
45
+
46
+ def required
47
+ @expected_optional = false
48
+ self
49
+ end
50
+
51
+ def with_default(default)
52
+ @check_default = true
53
+ @expected_default = default
54
+ self
55
+ end
56
+
57
+ def matches?(service_class)
58
+ @service_class = service_class
59
+ @actual_class = service_class.is_a?(Class) ? service_class : service_class.class
60
+
61
+ return false unless output_defined?
62
+ return false unless type_matches?
63
+ return false unless optional_matches?
64
+ return false unless default_matches?
65
+
66
+ true
67
+ end
68
+
69
+ def failure_message
70
+ return "expected #{@actual_class} to define output :#{@name}" unless output_defined?
71
+ return type_failure_message unless type_matches?
72
+ return optional_failure_message unless optional_matches?
73
+ return default_failure_message unless default_matches?
74
+
75
+ ""
76
+ end
77
+
78
+ def failure_message_when_negated
79
+ "expected #{@actual_class} not to define output :#{@name}"
80
+ end
81
+
82
+ def description
83
+ desc = "define output :#{@name}"
84
+ desc += " with type #{@expected_type}" if @expected_type
85
+ desc += " as optional" if @expected_optional == true
86
+ desc += " as required" if @expected_optional == false
87
+ desc += " with default #{@expected_default.inspect}" if @check_default
88
+ desc
89
+ end
90
+
91
+ private
92
+
93
+ def output_defined?
94
+ @actual_class.respond_to?(:outputs) && @actual_class.outputs.key?(@name)
95
+ end
96
+
97
+ def output
98
+ @output ||= @actual_class.outputs[@name]
99
+ end
100
+
101
+ def type_matches?
102
+ return true if @expected_type.nil?
103
+
104
+ actual_type = output.instance_variable_get(:@type)
105
+ actual_type == @expected_type
106
+ end
107
+
108
+ def optional_matches?
109
+ return true if @expected_optional.nil?
110
+
111
+ output.optional == @expected_optional
112
+ end
113
+
114
+ def default_matches?
115
+ return true unless @check_default
116
+
117
+ output.default_exists && output.default == @expected_default
118
+ end
119
+
120
+ def type_failure_message
121
+ actual_type = output.instance_variable_get(:@type)
122
+ "expected #{@actual_class} output :#{@name} to have type #{@expected_type}, " \
123
+ "but it has type #{actual_type.inspect}"
124
+ end
125
+
126
+ def optional_failure_message
127
+ if @expected_optional
128
+ "expected #{@actual_class} output :#{@name} to be optional, but it is required"
129
+ else
130
+ "expected #{@actual_class} output :#{@name} to be required, but it is optional"
131
+ end
132
+ end
133
+
134
+ def default_failure_message
135
+ if output.default_exists
136
+ "expected #{@actual_class} output :#{@name} to have default #{@expected_default.inspect}, " \
137
+ "but it has default #{output.default.inspect}"
138
+ else
139
+ "expected #{@actual_class} output :#{@name} to have default #{@expected_default.inspect}, " \
140
+ "but no default is defined"
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Light
4
+ module Services
5
+ module RSpec
6
+ module Matchers
7
+ # Matcher for testing step definitions on a service class
8
+ #
9
+ # @example Basic usage
10
+ # expect(MyService).to define_step(:validate)
11
+ #
12
+ # @example With always flag
13
+ # expect(MyService).to define_step(:cleanup).with_always(true)
14
+ #
15
+ # @example With if condition
16
+ # expect(MyService).to define_step(:notify).with_if(:should_notify?)
17
+ #
18
+ # @example With unless condition
19
+ # expect(MyService).to define_step(:skip_validation).with_unless(:production?)
20
+ #
21
+ # @example Check multiple steps
22
+ # expect(MyService).to define_steps(:validate, :process, :save)
23
+ #
24
+ # @example Check step order
25
+ # expect(MyService).to define_steps_in_order(:validate, :process, :save)
26
+ def define_step(name)
27
+ DefineStepMatcher.new(name)
28
+ end
29
+
30
+ def define_steps(*names)
31
+ DefineStepsMatcher.new(names, ordered: false)
32
+ end
33
+
34
+ def define_steps_in_order(*names)
35
+ DefineStepsMatcher.new(names, ordered: true)
36
+ end
37
+
38
+ class DefineStepMatcher
39
+ def initialize(name)
40
+ @name = name
41
+ @expected_always = nil
42
+ @expected_if = nil
43
+ @expected_unless = nil
44
+ end
45
+
46
+ def with_always(value = true)
47
+ @expected_always = value
48
+ self
49
+ end
50
+
51
+ def with_if(condition)
52
+ @expected_if = condition
53
+ self
54
+ end
55
+
56
+ def with_unless(condition)
57
+ @expected_unless = condition
58
+ self
59
+ end
60
+
61
+ def matches?(service_class)
62
+ @service_class = service_class
63
+ @actual_class = service_class.is_a?(Class) ? service_class : service_class.class
64
+
65
+ return false unless step_defined?
66
+ return false unless always_matches?
67
+ return false unless if_matches?
68
+ return false unless unless_matches?
69
+
70
+ true
71
+ end
72
+
73
+ def failure_message
74
+ return "expected #{@actual_class} to define step :#{@name}" unless step_defined?
75
+ return always_failure_message unless always_matches?
76
+ return if_failure_message unless if_matches?
77
+ return unless_failure_message unless unless_matches?
78
+
79
+ ""
80
+ end
81
+
82
+ def failure_message_when_negated
83
+ "expected #{@actual_class} not to define step :#{@name}"
84
+ end
85
+
86
+ def description
87
+ desc = "define step :#{@name}"
88
+ desc += " with always: #{@expected_always}" unless @expected_always.nil?
89
+ desc += " with if: #{@expected_if.inspect}" if @expected_if
90
+ desc += " with unless: #{@expected_unless.inspect}" if @expected_unless
91
+ desc
92
+ end
93
+
94
+ private
95
+
96
+ def step_defined?
97
+ @actual_class.respond_to?(:steps) && @actual_class.steps.key?(@name)
98
+ end
99
+
100
+ def step
101
+ @step ||= @actual_class.steps[@name]
102
+ end
103
+
104
+ def always_matches?
105
+ return true if @expected_always.nil?
106
+
107
+ step.always == @expected_always
108
+ end
109
+
110
+ def if_matches?
111
+ return true if @expected_if.nil?
112
+
113
+ actual_if = step.instance_variable_get(:@if)
114
+ actual_if == @expected_if
115
+ end
116
+
117
+ def unless_matches?
118
+ return true if @expected_unless.nil?
119
+
120
+ actual_unless = step.instance_variable_get(:@unless)
121
+ actual_unless == @expected_unless
122
+ end
123
+
124
+ def always_failure_message
125
+ "expected #{@actual_class} step :#{@name} to have always: #{@expected_always}, " \
126
+ "but it has always: #{step.always.inspect}"
127
+ end
128
+
129
+ def if_failure_message
130
+ actual_if = step.instance_variable_get(:@if)
131
+ "expected #{@actual_class} step :#{@name} to have if: #{@expected_if.inspect}, " \
132
+ "but it has if: #{actual_if.inspect}"
133
+ end
134
+
135
+ def unless_failure_message
136
+ actual_unless = step.instance_variable_get(:@unless)
137
+ "expected #{@actual_class} step :#{@name} to have unless: #{@expected_unless.inspect}, " \
138
+ "but it has unless: #{actual_unless.inspect}"
139
+ end
140
+ end
141
+
142
+ class DefineStepsMatcher
143
+ def initialize(names, ordered:)
144
+ @names = names
145
+ @ordered = ordered
146
+ end
147
+
148
+ def matches?(service_class)
149
+ @service_class = service_class
150
+ @actual_class = service_class.is_a?(Class) ? service_class : service_class.class
151
+ @missing_steps = []
152
+ @actual_order = []
153
+
154
+ return false unless all_steps_defined?
155
+ return false unless order_matches?
156
+
157
+ true
158
+ end
159
+
160
+ def failure_message
161
+ return missing_steps_failure_message unless all_steps_defined?
162
+ return order_failure_message unless order_matches?
163
+
164
+ ""
165
+ end
166
+
167
+ def failure_message_when_negated
168
+ if @ordered
169
+ "expected #{@actual_class} not to define steps #{@names.inspect} in that order"
170
+ else
171
+ "expected #{@actual_class} not to define steps #{@names.inspect}"
172
+ end
173
+ end
174
+
175
+ def description
176
+ if @ordered
177
+ "define steps #{@names.inspect} in order"
178
+ else
179
+ "define steps #{@names.inspect}"
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+ def all_steps_defined?
186
+ return false unless @actual_class.respond_to?(:steps)
187
+
188
+ actual_step_names = @actual_class.steps.keys
189
+ @missing_steps = @names - actual_step_names
190
+ @missing_steps.empty?
191
+ end
192
+
193
+ def order_matches?
194
+ return true unless @ordered
195
+
196
+ actual_step_names = @actual_class.steps.keys
197
+ @actual_order = @names.select { |name| actual_step_names.include?(name) }
198
+
199
+ # Check if the expected steps appear in the same order in actual steps
200
+ last_index = -1
201
+ @names.all? do |name|
202
+ current_index = actual_step_names.index(name)
203
+ return false unless current_index
204
+ return false unless current_index > last_index
205
+
206
+ last_index = current_index
207
+ true
208
+ end
209
+ end
210
+
211
+ def missing_steps_failure_message
212
+ "expected #{@actual_class} to define steps #{@names.inspect}, " \
213
+ "but missing: #{@missing_steps.inspect}"
214
+ end
215
+
216
+ def order_failure_message
217
+ actual_step_names = @actual_class.steps.keys
218
+ "expected #{@actual_class} to define steps #{@names.inspect} in that order, " \
219
+ "but actual order is: #{actual_step_names.inspect}"
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end