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
@@ -0,0 +1,222 @@
1
+ # Service Rendering
2
+
3
+ This recipe provides a clean way to render service results and errors in your Rails controllers, reducing boilerplate and ensuring consistent API responses.
4
+
5
+ ## The Problem
6
+
7
+ Without a helper, controller actions become repetitive:
8
+
9
+ ```ruby
10
+ class PostsController < ApplicationController
11
+ def create
12
+ service = Post::Create.run(service_args(attributes: params[:post]))
13
+
14
+ if service.success?
15
+ render json: service.post, status: :created
16
+ else
17
+ render json: { errors: service.errors.to_h }, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ def update
22
+ service = Post::Update.run(service_args(record: @post, attributes: params[:post]))
23
+
24
+ if service.success?
25
+ render json: service.post
26
+ else
27
+ render json: { errors: service.errors.to_h }, status: :unprocessable_entity
28
+ end
29
+ end
30
+
31
+ # ... same pattern repeated for every action
32
+ end
33
+ ```
34
+
35
+ ## The Solution
36
+
37
+ Create a `render_service` helper that handles success and failure automatically.
38
+
39
+ ## Implementation
40
+
41
+ ### Basic Helper
42
+
43
+ Add this to your `ApplicationController`:
44
+
45
+ ```ruby
46
+ class ApplicationController < ActionController::API
47
+ private
48
+
49
+ def render_service(service, success_status: :ok, error_status: :unprocessable_entity)
50
+ if service.success?
51
+ yield(service) if block_given?
52
+ render json: service_response(service), status: success_status
53
+ else
54
+ render json: { errors: service.errors.to_h }, status: error_status
55
+ end
56
+ end
57
+
58
+ def service_response(service)
59
+ # Returns the first output that is set
60
+ service.class.outputs.each do |name, _|
61
+ value = service.public_send(name)
62
+ return value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
63
+ end
64
+
65
+ {}
66
+ end
67
+ end
68
+ ```
69
+
70
+ ### Usage
71
+
72
+ ```ruby
73
+ class PostsController < ApplicationController
74
+ def create
75
+ render_service Post::Create.run(service_args(attributes: params[:post])),
76
+ success_status: :created
77
+ end
78
+
79
+ def update
80
+ render_service Post::Update.run(service_args(record: @post))
81
+ end
82
+
83
+ def destroy
84
+ render_service Post::Destroy.run(service_args(record: @post))
85
+ end
86
+ end
87
+ ```
88
+
89
+ ## Advanced Implementation
90
+
91
+ ### With Custom Response Building
92
+
93
+ ```ruby
94
+ class ApplicationController < ActionController::API
95
+ private
96
+
97
+ def render_service(service, **options)
98
+ if service.success?
99
+ render_service_success(service, options)
100
+ else
101
+ render_service_failure(service, options)
102
+ end
103
+ end
104
+
105
+ def render_service_success(service, options)
106
+ status = options[:success_status] || :ok
107
+
108
+ response = if options[:response]
109
+ options[:response]
110
+ elsif options[:output]
111
+ service.public_send(options[:output])
112
+ else
113
+ auto_detect_response(service)
114
+ end
115
+
116
+ render json: response, status: status
117
+ end
118
+
119
+ def render_service_failure(service, options)
120
+ status = options[:error_status] || :unprocessable_entity
121
+
122
+ render json: {
123
+ errors: service.errors.to_h,
124
+ warnings: service.warnings.to_h
125
+ }.compact, status: status
126
+ end
127
+
128
+ def auto_detect_response(service)
129
+ service.class.outputs.each do |name, _|
130
+ value = service.public_send(name)
131
+ return value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
132
+ end
133
+
134
+ { success: true }
135
+ end
136
+ end
137
+ ```
138
+
139
+ ### Usage with Options
140
+
141
+ ```ruby
142
+ class PostsController < ApplicationController
143
+ def create
144
+ service = Post::Create.run(service_args(attributes: params[:post]))
145
+
146
+ render_service service,
147
+ success_status: :created,
148
+ output: :post
149
+ end
150
+
151
+ def bulk_create
152
+ service = Post::BulkCreate.run(service_args(items: params[:posts]))
153
+
154
+ render_service service,
155
+ success_status: :created,
156
+ response: { posts: service.posts, count: service.posts.count }
157
+ end
158
+ end
159
+ ```
160
+
161
+ ## With Serializers
162
+
163
+ If you're using a serializer library (like Alba, Blueprinter, or ActiveModel::Serializers):
164
+
165
+ ```ruby
166
+ class ApplicationController < ActionController::API
167
+ private
168
+
169
+ def render_service(service, serializer: nil, **options)
170
+ if service.success?
171
+ response = auto_detect_response(service)
172
+ response = serializer.new(response).to_h if serializer && response
173
+
174
+ render json: response, status: options[:success_status] || :ok
175
+ else
176
+ render json: { errors: service.errors.to_h },
177
+ status: options[:error_status] || :unprocessable_entity
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ ```ruby
184
+ class PostsController < ApplicationController
185
+ def show
186
+ service = Post::Find.run(service_args(id: params[:id]))
187
+ render_service service, serializer: PostSerializer
188
+ end
189
+ end
190
+ ```
191
+
192
+ ## Handling Different Error Types
193
+
194
+ ```ruby
195
+ def render_service(service, **options)
196
+ if service.success?
197
+ render_service_success(service, options)
198
+ else
199
+ status = determine_error_status(service, options)
200
+ render json: { errors: service.errors.to_h }, status: status
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ def determine_error_status(service, options)
207
+ return options[:error_status] if options[:error_status]
208
+
209
+ # Map specific error keys to HTTP statuses
210
+ return :not_found if service.errors[:record]&.any?
211
+ return :forbidden if service.errors[:authorization]&.any?
212
+ return :unauthorized if service.errors[:authentication]&.any?
213
+
214
+ :unprocessable_entity
215
+ end
216
+ ```
217
+
218
+ ## What's Next?
219
+
220
+ Learn how to integrate Pundit authorization with Light Services:
221
+
222
+ [Next: Pundit Authorization](pundit-authorization.md)
data/docs/steps.md ADDED
@@ -0,0 +1,337 @@
1
+ # Steps
2
+
3
+ Steps are the core components of a service, each representing a unit of work executed in sequence when the service is called.
4
+
5
+ ## TL;DR
6
+
7
+ - Define steps using the `step` keyword within the service class
8
+ - Use `if` and `unless` options for conditional steps
9
+ - Inherit steps from parent classes
10
+ - Inject steps into the execution flow with `before` and `after` options
11
+ - Ensure cleanup steps run with the `always: true` option (unless `done!` was called)
12
+ - Use a `run` method as a simple alternative for single-step services
13
+
14
+ ```ruby
15
+ class GeneralParserService < ApplicationService
16
+ step :create_browser, unless: :browser
17
+ step :parse_content
18
+ step :quit_browser, always: true
19
+ end
20
+
21
+ class ParsePage < GeneralParserService
22
+ step :parse_additional_content, after: :parse_content
23
+ end
24
+ ```
25
+
26
+ ## Define Steps
27
+
28
+ Steps are declared using the `step` keyword in your service class.
29
+
30
+ ```ruby
31
+ class User::Charge < ApplicationService
32
+ step :authorize
33
+ step :charge
34
+ step :send_email_receipt
35
+
36
+ private
37
+
38
+ def authorize
39
+ # ...
40
+ end
41
+
42
+ def charge
43
+ # ...
44
+ end
45
+
46
+ def send_email_receipt
47
+ # ...
48
+ end
49
+ end
50
+ ```
51
+
52
+ ## Conditional Steps
53
+
54
+ Steps can be conditional, executed based on specified conditions using the `if` or `unless` keywords.
55
+
56
+ ```ruby
57
+ class User::Charge < ApplicationService
58
+ step :authorize
59
+ step :charge
60
+ step :send_email_receipt, if: :send_receipt?
61
+
62
+ # ...
63
+
64
+ def send_receipt?
65
+ rand(2).zero?
66
+ end
67
+ end
68
+ ```
69
+
70
+ This feature works well with argument predicates.
71
+
72
+ ```ruby
73
+ class User::Charge < ApplicationService
74
+ arg :send_receipt, type: [TrueClass, FalseClass], default: true
75
+
76
+ step :send_email_receipt, if: :send_receipt?
77
+
78
+ # ...
79
+ end
80
+ ```
81
+
82
+ ### Using Procs for Conditions
83
+
84
+ You can also use Procs (lambdas) for inline conditions:
85
+
86
+ ```ruby
87
+ class User::Charge < ApplicationService
88
+ arg :amount, type: Float
89
+
90
+ step :apply_discount, if: -> { amount > 100 }
91
+ step :charge
92
+ step :send_large_purchase_alert, if: -> { amount > 1000 }
93
+
94
+ # ...
95
+ end
96
+ ```
97
+
98
+ {% hint style="info" %}
99
+ Using Procs can make simple conditions more readable, but for complex logic, prefer extracting to a method.
100
+ {% endhint %}
101
+
102
+ ## Inheritance
103
+
104
+ Steps are inherited from parent classes, making it easy to build upon existing services.
105
+
106
+ ```ruby
107
+ # UpdateRecordService
108
+ class UpdateRecordService < ApplicationService
109
+ arg :record, type: ApplicationRecord
110
+ arg :attributes, type: Hash
111
+
112
+ step :authorize
113
+ step :update_record
114
+ end
115
+ ```
116
+
117
+ ```ruby
118
+ # User::Update inherited from UpdateRecordService
119
+ class User::Update < UpdateRecordService
120
+ # Arguments and steps are inherited from UpdateRecordService
121
+ end
122
+ ```
123
+
124
+ ## Injecting Steps into Execution Flow
125
+
126
+ Steps can be injected at specific points in the execution flow using `before` and `after` options.
127
+
128
+ Let's enhance the previous example by adding a step to send a notification after updating the record.
129
+
130
+ ```ruby
131
+ # User::Update inherited from UpdateRecordService
132
+ class User::Update < UpdateRecordService
133
+ step :log_action, before: :authorize
134
+ step :send_notification, after: :update_record
135
+
136
+ private
137
+
138
+ def log_action
139
+ # ...
140
+ end
141
+
142
+ def send_notification
143
+ # ...
144
+ end
145
+ end
146
+ ```
147
+
148
+ Combine this with `if` and `unless` options for more control.
149
+
150
+ ```ruby
151
+ step :send_notification, after: :update_record, if: :send_notification?
152
+ ```
153
+
154
+ {% hint style="info" %}
155
+ By default, if neither `before` nor `after` is specified, the step is added at the end of the execution flow.
156
+ {% endhint %}
157
+
158
+ ## Always Running Steps
159
+
160
+ To ensure certain steps run regardless of previous step outcomes (errors, warnings, failed validations), use the `always: true` option. This is particularly useful for cleanup tasks, error logging, etc.
161
+
162
+ Note: if `done!` was called, the service exits early and `always: true` steps will **not** run.
163
+
164
+ ```ruby
165
+ class ParsePage < ApplicationService
166
+ arg :url, type: String
167
+
168
+ step :create_browser
169
+ step :parse_content
170
+ step :quit_browser, always: true
171
+
172
+ private
173
+
174
+ attr_accessor :browser
175
+
176
+ def create_browser
177
+ self.browser = Watir::Browser.new
178
+ end
179
+
180
+ def parse_content
181
+ # ...
182
+ end
183
+
184
+ def quit_browser
185
+ browser&.quit
186
+ end
187
+ end
188
+ ```
189
+
190
+ ## Early Exit with `done!`
191
+
192
+ Use `done!` to stop executing remaining steps without adding an error. This is useful when you've completed the service's goal early and don't need to run subsequent steps.
193
+
194
+ ```ruby
195
+ class User::FindOrCreate < ApplicationService
196
+ arg :email, type: String
197
+
198
+ step :find_existing_user
199
+ step :create_user
200
+ step :send_welcome_email
201
+
202
+ output :user
203
+
204
+ private
205
+
206
+ def find_existing_user
207
+ self.user = User.find_by(email:)
208
+ done! if user # Skip remaining steps if user already exists
209
+ end
210
+
211
+ def create_user
212
+ self.user = User.create!(email:)
213
+ end
214
+
215
+ def send_welcome_email
216
+ # Only runs for newly created users
217
+ Mailer.welcome(user).deliver_later
218
+ end
219
+ end
220
+ ```
221
+
222
+ You can check if `done!` was called using `done?`:
223
+
224
+ ```ruby
225
+ def some_step
226
+ done!
227
+
228
+ # This code still runs within the same step
229
+ puts "Done? #{done?}" # => "Done? true"
230
+ end
231
+
232
+ def next_step
233
+ # This step will NOT run because done! was called
234
+ end
235
+ ```
236
+
237
+ {% hint style="info" %}
238
+ `done!` stops subsequent steps from running, including steps marked with `always: true`. Code after `done!` within the same step method will still execute.
239
+ {% endhint %}
240
+
241
+ ## Removing Inherited Steps
242
+
243
+ When inheriting from a parent service, you can remove steps using `remove_step`:
244
+
245
+ ```ruby
246
+ class UpdateRecordService < ApplicationService
247
+ step :authorize
248
+ step :validate
249
+ step :update_record
250
+ step :send_notification
251
+ end
252
+
253
+ class InternalUpdate < UpdateRecordService
254
+ # Remove authorization for internal system updates
255
+ remove_step :authorize
256
+ remove_step :send_notification
257
+ end
258
+ ```
259
+
260
+ ## Using `run` Method as a Simple Alternative
261
+
262
+ For simple services that don't need multiple steps, you can define a `run` method instead of using the `step` DSL. If no steps are defined, Light Services will automatically use the `run` method as a single step.
263
+
264
+ ```ruby
265
+ class User::SendWelcomeEmail < ApplicationService
266
+ arg :user, type: User
267
+
268
+ private
269
+
270
+ def run
271
+ Mailer.welcome(user).deliver_later
272
+ end
273
+ end
274
+ ```
275
+
276
+ This is equivalent to:
277
+
278
+ ```ruby
279
+ class User::SendWelcomeEmail < ApplicationService
280
+ arg :user, type: User
281
+
282
+ step :run
283
+
284
+ private
285
+
286
+ def run
287
+ Mailer.welcome(user).deliver_later
288
+ end
289
+ end
290
+ ```
291
+
292
+ ### Inheritance with `run` Method
293
+
294
+ The `run` method works with inheritance. If a parent service defines a `run` method, child services will inherit it:
295
+
296
+ ```ruby
297
+ class BaseNotificationService < ApplicationService
298
+ arg :message, type: String
299
+
300
+ private
301
+
302
+ def run
303
+ send_notification(message)
304
+ end
305
+
306
+ def send_notification(msg)
307
+ raise NotImplementedError
308
+ end
309
+ end
310
+
311
+ class SlackNotification < BaseNotificationService
312
+ private
313
+
314
+ def send_notification(msg)
315
+ SlackClient.post(msg)
316
+ end
317
+ end
318
+
319
+ class EmailNotification < BaseNotificationService
320
+ private
321
+
322
+ def send_notification(msg)
323
+ Mailer.notify(msg).deliver_later
324
+ end
325
+ end
326
+ ```
327
+
328
+ {% hint style="info" %}
329
+ If a service has no steps defined and no `run` method (including from parent classes), a `Light::Services::NoStepsError` will be raised when the service is executed.
330
+ {% endhint %}
331
+
332
+ # What's Next?
333
+
334
+ Next step is to learn about outputs. Outputs are the results of a service, returned upon completion of service execution.
335
+
336
+ [Next: Outputs](outputs.md)
337
+
data/docs/summary.md ADDED
@@ -0,0 +1,19 @@
1
+ # Table of contents
2
+
3
+ * [Light Services](README.md)
4
+ * [Quickstart](quickstart.md)
5
+ * [Concepts](concepts.md)
6
+ * [Arguments](arguments.md)
7
+ * [Steps](steps.md)
8
+ * [Outputs](outputs.md)
9
+ * [Context](context.md)
10
+ * [Errors](errors.md)
11
+ * [Callbacks](callbacks.md)
12
+ * [Configuration](configuration.md)
13
+ * [Testing](testing.md)
14
+ * [Rails Generators](generators.md)
15
+ * [Best Practices](best-practices.md)
16
+ * [Recipes](recipes.md)
17
+ * [CRUD](crud.md)
18
+ * [Service Rendering](service-rendering.md)
19
+ * [Pundit Authorization](pundit-authorization.md)