light-services 2.2 → 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 (74) 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 +5 -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 +24 -114
  39. data/lib/light/services/base_with_context.rb +2 -3
  40. data/lib/light/services/callbacks.rb +103 -0
  41. data/lib/light/services/collection.rb +97 -0
  42. data/lib/light/services/concerns/execution.rb +76 -0
  43. data/lib/light/services/concerns/parent_service.rb +34 -0
  44. data/lib/light/services/concerns/state_management.rb +30 -0
  45. data/lib/light/services/config.rb +4 -18
  46. data/lib/light/services/constants.rb +97 -0
  47. data/lib/light/services/dsl/arguments_dsl.rb +84 -0
  48. data/lib/light/services/dsl/outputs_dsl.rb +80 -0
  49. data/lib/light/services/dsl/steps_dsl.rb +205 -0
  50. data/lib/light/services/dsl/validation.rb +132 -0
  51. data/lib/light/services/exceptions.rb +7 -2
  52. data/lib/light/services/messages.rb +19 -31
  53. data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
  54. data/lib/light/services/rspec/matchers/define_output.rb +147 -0
  55. data/lib/light/services/rspec/matchers/define_step.rb +225 -0
  56. data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
  57. data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
  58. data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
  59. data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
  60. data/lib/light/services/rspec.rb +15 -0
  61. data/lib/light/services/settings/field.rb +86 -0
  62. data/lib/light/services/settings/step.rb +31 -16
  63. data/lib/light/services/utils.rb +38 -0
  64. data/lib/light/services/version.rb +1 -1
  65. data/lib/light/services.rb +2 -0
  66. data/light-services.gemspec +6 -8
  67. metadata +54 -26
  68. data/lib/light/services/class_based_collection/base.rb +0 -86
  69. data/lib/light/services/class_based_collection/mount.rb +0 -33
  70. data/lib/light/services/collection/arguments.rb +0 -34
  71. data/lib/light/services/collection/base.rb +0 -59
  72. data/lib/light/services/collection/outputs.rb +0 -16
  73. data/lib/light/services/settings/argument.rb +0 -68
  74. data/lib/light/services/settings/output.rb +0 -34
data/docs/context.md ADDED
@@ -0,0 +1,128 @@
1
+ # Context
2
+
3
+ Context allows services to be run within the same execution scope, enabling shared state and coordinated transactions.
4
+
5
+ ## Key Features
6
+
7
+ - Services share arguments marked as `context: true`
8
+ - If any service fails, the entire context fails and rolls back database changes
9
+
10
+ ## How to Run Services in the Same Context
11
+
12
+ To run a service in the same context, call `with(self)` before the `#run` method.
13
+
14
+ ## Context Rollback
15
+
16
+ ### Example:
17
+
18
+ Let's say we have two services: `User::Create` and `Profile::Create`. We want to ensure that if either service fails, all database changes are rolled back.
19
+
20
+ ```ruby
21
+ class User::Create < ApplicationService
22
+ # Arguments
23
+ arg :attributes, type: Hash
24
+
25
+ # Steps
26
+ step :create_user
27
+ step :create_profile
28
+ step :send_welcome_email
29
+
30
+ # Outputs
31
+ output :user, type: User
32
+ output :profile, type: Profile
33
+
34
+ def create_user
35
+ self.user = User.create!(attributes)
36
+ end
37
+
38
+ def create_profile
39
+ service = Profile::Create
40
+ .with(self) # This runs the service in the same context
41
+ .run(user:)
42
+
43
+ self.profile = service.profile
44
+ end
45
+
46
+ # If the Profile::Create service fails, this step and any following steps won't execute
47
+ # And all database changes will be rolled back
48
+ def send_welcome_email
49
+ # We don't run this service in the same context
50
+ # Because we don't care too much if it fails
51
+ service = Mailer::SendWelcomeEmail.run(user:)
52
+
53
+ # Handle the failure manually if needed
54
+ if service.failed?
55
+ # Handle the failure
56
+ end
57
+ end
58
+ end
59
+ ```
60
+
61
+ ## Context Arguments
62
+
63
+ Context arguments are shared between services running in the same context. This can make them a bit less predictable and harder to test.
64
+
65
+ It's recommended to use context arguments only when necessary and keep them as close to the root service as possible. For example, you can use them to share `current_user` or `current_organization` between services.
66
+
67
+ ```ruby
68
+ class ApplicationService < Light::Services::Base
69
+ arg :current_user, type: User, context: true
70
+ end
71
+ ```
72
+
73
+ ```ruby
74
+ class Comment::Create < ApplicationService
75
+ # Arguments
76
+ # We don't need to specify current_user here
77
+ # as it's automatically inherited from the ApplicationService
78
+ arg :post_id, type: Integer
79
+ arg :text, type: String
80
+ arg :subscribe, type: [TrueClass, FalseClass]
81
+
82
+ # Steps
83
+ step :create_comment
84
+ step :subscribe_to_post, if: :subscribe?
85
+
86
+ private
87
+
88
+ def create_comment
89
+ # ...
90
+ end
91
+
92
+ def subscribe_to_post
93
+ Post::Subscribe
94
+ .with(self) # Run service in the same context
95
+ .run(post_id:) # We omit current_user here as context will handle it for us
96
+
97
+ # If we run Post::Subscribe without `with(self)`
98
+ # It'll fail because it won't have information about the `current_user`
99
+ end
100
+ end
101
+ ```
102
+
103
+ ```ruby
104
+ class Post::Subscribe < ApplicationService
105
+ # Arguments
106
+ arg :post_id, type: Integer
107
+
108
+ # Steps
109
+ step :subscribe
110
+
111
+ private
112
+
113
+ def subscribe
114
+ # We have access to current_user here because we run it in the same context
115
+ #
116
+ # Even if we would run this service without context this won't be a problem
117
+ # because we specified this argument in top-level service (ApplicationService)
118
+ current_user.subscriptions.create!(post_id:)
119
+ end
120
+ end
121
+ ```
122
+
123
+ # What's Next?
124
+
125
+ The next step is to learn about error handling in Light Service.
126
+
127
+ [Next: Errors](errors.md)
128
+
data/docs/crud.md ADDED
@@ -0,0 +1,525 @@
1
+ # CRUD
2
+
3
+ In this recipe we'll create top-level CRUD services to manage our records.
4
+
5
+ This approach has been tested in production for many years and has saved significant time and effort.
6
+
7
+ ## Why
8
+
9
+ We want to put all the common logic for creating, updating, destroying and finding records in one place.
10
+
11
+ We want to minimize possibility of a mistake by putting logic as close to core as possible.
12
+
13
+ ## How to use it in controllers
14
+
15
+ ```ruby
16
+ class PostsController < ApplicationController
17
+ def index
18
+ render json: crud_find_all(Post)
19
+ end
20
+
21
+ def show
22
+ render json: crud_find(Post)
23
+ end
24
+
25
+ def create
26
+ render_service crud_create(Post)
27
+ end
28
+
29
+ def update
30
+ render_service crud_update(Post)
31
+ end
32
+
33
+ def destroy
34
+ render_service crud_destroy(Post)
35
+ end
36
+ end
37
+ ```
38
+
39
+ {% hint style="info" %}
40
+ `render_service` method is a method from [Rendering Services](service-rendering.md) recipe.
41
+ {% endhint %}
42
+
43
+ ## How to use it in services
44
+
45
+ ```ruby
46
+ class ParseProfiles < ApplicationService
47
+ # ...
48
+
49
+ def create_profiles
50
+ profiles.each do |profile|
51
+ # Create profile service is automatically run within the same context
52
+ create(
53
+ Profile,
54
+ name: profile.name,
55
+ age: profile.age,
56
+ )
57
+ end
58
+ end
59
+ end
60
+ ```
61
+
62
+ ## Customization
63
+
64
+ You don't need to create a new service for every CRUD operation. But you can create a service for specific model if you need to customize the behavior.
65
+
66
+ Just create a service called `{Model}::Create`, `{Model}::Update`, `{Model}::Destroy` and inherit from `CreateRecordService`, `UpdateRecordService`, `DestroyRecordService` respectively.
67
+
68
+ For example:
69
+
70
+ **Adding additional steps to create user:**
71
+
72
+ ```ruby
73
+ class User::Create < CreateRecordService
74
+ step :create_profile
75
+
76
+ private
77
+
78
+ def create_profile
79
+ create!(Profile, user:)
80
+ end
81
+ end
82
+ ```
83
+
84
+ **Setting default attributes:**
85
+
86
+ ```ruby
87
+ class Post::Create < CreateRecordService
88
+ private
89
+
90
+ def default_attributes
91
+ { status: :draft }
92
+ end
93
+ end
94
+ ```
95
+
96
+ **Override attributes:**
97
+
98
+ ```ruby
99
+ class Post::Update < UpdateRecordService
100
+ private
101
+
102
+ def override_attributes
103
+ { updated_by: current_user }
104
+ end
105
+ end
106
+ ```
107
+
108
+ **Skipping step:**
109
+
110
+ ```ruby
111
+ class Post::Destroy < DestroyRecordService
112
+ remove_step :authorize
113
+ end
114
+ ```
115
+
116
+ ## Code
117
+
118
+ {% hint style="info" %}
119
+ This code is just a starting point. You can customize it to fit your needs.
120
+ {% endhint %}
121
+
122
+ {% hint style="info" %}
123
+ You need to add `Pundit Authorization` and `Request Concern` to make this code work.
124
+ {% endhint %}
125
+
126
+ **app/services/create\_record\_service.rb:**
127
+
128
+ ```ruby
129
+ class CreateRecordService < ApplicationService
130
+ # Arguments
131
+ arg :record_class, type: Class, default: -> { self.class.module_parent }
132
+ arg :attributes, type: Hash, default: {}
133
+
134
+ # Steps
135
+ step :create_alias
136
+ step :authorize_user
137
+ step :initialize_record
138
+ step :assign_attributes
139
+ step :save_record
140
+
141
+ # Outputs
142
+ output :record
143
+
144
+ private
145
+
146
+ # Create a readable alias for the record based on the class name (e.g. `user` for `User`)
147
+ def create_alias
148
+ define_singleton_method(record_class.to_s.underscore) { record }
149
+ end
150
+
151
+ # Check if the user is authorized to create a record
152
+ def authorize_user
153
+ auth(record_class, :create?)
154
+ end
155
+
156
+ # Initialize a new record
157
+ def initialize_record
158
+ self.record = record_class.new
159
+ end
160
+
161
+ # Assign attributes to the record
162
+ def assign_attributes
163
+ assign_attributes = default_attributes
164
+ .merge(params_attributes)
165
+ .merge(attributes)
166
+ .merge(override_attributes)
167
+
168
+ record.assign_attributes(assign_attributes)
169
+ end
170
+
171
+ # Save the record
172
+ def save_record
173
+ record.save_with!(self)
174
+ end
175
+
176
+ # Extract permitted attributes using Pundit
177
+ def params_attributes
178
+ return {} if !attributes.nil? && !(attributes.respond_to?(:empty?) && attributes.empty?)
179
+
180
+ permitted_attributes(record, :create)
181
+ rescue ActionController::ParameterMissing
182
+ {}
183
+ rescue Pundit::NotDefinedError
184
+ raise unless system
185
+
186
+ {}
187
+ end
188
+
189
+ # Default attributes, which can be overridden in subclasses
190
+ def default_attributes
191
+ {}
192
+ end
193
+
194
+ # Override attributes, which can be overridden in subclasses
195
+ def override_attributes
196
+ {}
197
+ end
198
+ end
199
+ ```
200
+
201
+ **app/services/update\_record\_service.rb:**
202
+
203
+ ```ruby
204
+ class UpdateRecordService < ApplicationService
205
+ # Arguments
206
+ arg :record, type: ActiveRecord::Base
207
+ arg :attributes, type: Hash, default: {}
208
+
209
+ # Steps
210
+ step :create_alias
211
+ step :validate_record_class
212
+ step :authorize_user
213
+ step :assign_attributes
214
+ step :save_record
215
+
216
+ private
217
+
218
+ # Create a readable alias for the record based on the class name (e.g. `user` for `User`)
219
+ def create_alias
220
+ define_singleton_method(record.class.to_s.underscore) { record }
221
+ end
222
+
223
+ # Make sure record is an instance of the correct class
224
+ def validate_record_class
225
+ return if self.class.module_parent == Object # No parent module
226
+ return if self.class.module_parent == record.class
227
+
228
+ errors.add(:base, "record must be #{self.class.module_parent}")
229
+ end
230
+
231
+ # Check if the user is authorized to update this record
232
+ def authorize_user
233
+ auth(record, :update?) if attributes.nil? || (attributes.respond_to?(:empty?) && attributes.empty?)
234
+ end
235
+
236
+ # Assign attributes to the record
237
+ def assign_attributes
238
+ assign_attributes = default_attributes
239
+ .merge(params_attributes)
240
+ .merge(attributes)
241
+ .merge(override_attributes)
242
+
243
+ record.assign_attributes(assign_attributes)
244
+ end
245
+
246
+ # Save the record
247
+ def save_record
248
+ record.save!
249
+ rescue ActiveRecord::RecordInvalid
250
+ errors.copy_from(record)
251
+ end
252
+
253
+ # Extract permitted attributes from params using Pundit
254
+ def params_attributes
255
+ return {} if !attributes.nil? && !(attributes.respond_to?(:empty?) && attributes.empty?)
256
+
257
+ permitted_attributes(record, :update)
258
+ rescue ActionController::ParameterMissing
259
+ {}
260
+ rescue Pundit::NotDefinedError
261
+ raise unless system
262
+
263
+ {}
264
+ end
265
+
266
+ # Default attributes, which can be overridden in subclasses
267
+ def default_attributes
268
+ {}
269
+ end
270
+
271
+ # Overridden attributes, which can be overridden in subclasses
272
+ def override_attributes
273
+ {}
274
+ end
275
+ end
276
+ ```
277
+
278
+ **app/services/destroy\_record\_service.rb:**
279
+
280
+ ```ruby
281
+ class DestroyRecordService < ApplicationService
282
+ # Arguments
283
+ arg :record, type: ActiveRecord::Base
284
+ arg :attributes, type: Hash, default: {}
285
+
286
+ # Steps
287
+ step :create_alias
288
+ step :authorize_user
289
+ step :destroy_record
290
+
291
+ private
292
+
293
+ # Create a readable alias for the record based on the class name (e.g. `user` for `User`)
294
+ def create_alias
295
+ define_singleton_method(record.class.to_s.underscore) { record }
296
+ end
297
+
298
+ # Check if the user is authorized to update this record
299
+ def authorize_user
300
+ auth(record, :destroy?)
301
+ end
302
+
303
+ # Delete the record
304
+ def destroy_record
305
+ record.destroy!
306
+ rescue ActiveRecord::RecordNotDestroyed
307
+ errors.copy_from(record)
308
+ end
309
+ end
310
+ ```
311
+
312
+ **app/controller/application\_controller.rb:**
313
+
314
+ ```ruby
315
+ class ApplicationController < ActionController::Base
316
+ # Includes
317
+ include CRUDControllers
318
+ include AuthenticateUser
319
+
320
+ private
321
+
322
+ def service_args(hash = {})
323
+ hash.reverse_merge(
324
+ params:,
325
+ request:,
326
+ current_user:,
327
+ current_administrator:,
328
+ )
329
+ end
330
+ end
331
+ ```
332
+
333
+ **app/controllers/concerns/crud\_controllers.rb:**
334
+
335
+ ```ruby
336
+ module CRUDControllers
337
+ extend ActiveSupport::Concern
338
+
339
+ included do
340
+ def crud_find(klass, args = {})
341
+ crud_service(
342
+ klass,
343
+ "Find",
344
+ FindRecordService,
345
+ args.merge(record_class: klass),
346
+ ).record
347
+ end
348
+
349
+ def crud_find_all(klass, args = {})
350
+ crud_service(
351
+ klass,
352
+ "FindAll",
353
+ FindAllRecordsService,
354
+ args.merge(record_class: klass),
355
+ ).scope
356
+ end
357
+
358
+ def crud_create(klass, args = {})
359
+ crud_service(
360
+ klass,
361
+ "Create",
362
+ CreateRecordService,
363
+ args.merge(record_class: klass),
364
+ )
365
+ end
366
+
367
+ def crud_update(record, args = {})
368
+ crud_service(
369
+ record.class,
370
+ "Update",
371
+ UpdateRecordService,
372
+ args.merge(record:),
373
+ )
374
+ end
375
+
376
+ def crud_destroy(record, args = {})
377
+ crud_service(
378
+ record.class,
379
+ "Destroy",
380
+ DestroyRecordService,
381
+ args.merge(record:),
382
+ )
383
+ end
384
+
385
+ private
386
+
387
+ def crud_service(klass, class_postfix, default_class, args)
388
+ begin
389
+ service_class = "#{klass}::#{class_postfix}".constantize
390
+ rescue NameError
391
+ service_class = default_class
392
+ end
393
+
394
+ service_class.run(service_args(args))
395
+ end
396
+ end
397
+ end
398
+ ```
399
+
400
+ **app/services/application\_service.rb:**
401
+
402
+ ```ruby
403
+ class ApplicationService < Light::Services::Base
404
+ # Includes
405
+ include CRUDServices
406
+ include RequestConcern
407
+ end
408
+ ```
409
+
410
+ **app/services/concerns/crud\_services.rb:**
411
+
412
+ ```ruby
413
+ module CRUDServices
414
+ extend ActiveSupport::Concern
415
+
416
+ included do
417
+ def find(klass, args = {})
418
+ run_service(
419
+ klass,
420
+ "Find",
421
+ FindRecordService,
422
+ args.merge(record_class: klass),
423
+ )
424
+ end
425
+
426
+ def find_all(klass, args = {})
427
+ args.reverse_merge!(no_filters: true)
428
+
429
+ run_service(
430
+ klass,
431
+ "FindAll",
432
+ FindAllRecordsService,
433
+ args.merge(record_class: klass),
434
+ plural_output: true,
435
+ )
436
+ end
437
+
438
+ def create(klass, attributes = {}, args = {})
439
+ run_service(
440
+ klass,
441
+ "Create",
442
+ CreateRecordService,
443
+ args.merge(record_class: klass, attributes:),
444
+ )
445
+ end
446
+
447
+ def create!(klass, attributes = {}, args = {})
448
+ create(klass, attributes, args.merge(raise_on_error: true))
449
+ end
450
+
451
+ def update(record, attributes = {}, args = {})
452
+ run_service(
453
+ record.class,
454
+ "Update",
455
+ UpdateRecordService,
456
+ args.merge(record:, attributes:),
457
+ )
458
+ end
459
+
460
+ def update!(record, attributes = {}, args = {})
461
+ update(record, attributes, args.merge(raise_on_error: true))
462
+ end
463
+
464
+ def destroy(record, args = {})
465
+ run_service(
466
+ record.class,
467
+ "Destroy",
468
+ DestroyRecordService,
469
+ args.merge(record:),
470
+ )
471
+ end
472
+
473
+ def destroy!(record, args = {})
474
+ destroy(record, args.merge(raise_on_error: true))
475
+ end
476
+
477
+ def create_or_update!(klass, record, attributes = {}, args = {})
478
+ if record
479
+ update!(record, attributes, args)
480
+ else
481
+ create!(klass, attributes, args)
482
+ end
483
+ end
484
+
485
+ private
486
+
487
+ def resource_name(klass, plural: false)
488
+ name = klass.name.demodulize.underscore
489
+ plural ? name.pluralize : name
490
+ end
491
+
492
+ def run_service(klass, class_postfix, default_class, args, opts = {})
493
+ begin
494
+ service_class = "#{klass}::#{class_postfix}".constantize
495
+ rescue NameError
496
+ service_class = default_class
497
+ end
498
+
499
+ service_class
500
+ .with(self)
501
+ .run(args)
502
+ .public_send(resource_name(klass, plural: opts[:plural_output]))
503
+ end
504
+ end
505
+ end
506
+ ```
507
+
508
+ **app/services/concerns/request\_concern.rb:**
509
+
510
+ ```ruby
511
+ module RequestConcern
512
+ extend ActiveSupport::Concern
513
+
514
+ included do
515
+ arg :params, type: [Hash, ActionController::Parameters], default: ActionController::Parameters.new({}), context: true
516
+ arg :request, type: ActionDispatch::Request, default: ActionDispatch::Request.new({}), context: true
517
+ end
518
+ end
519
+ ```
520
+
521
+ ## What's Next?
522
+
523
+ Learn how to render service results cleanly in your controllers:
524
+
525
+ [Next: Service Rendering](service-rendering.md)