light-services 2.2.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/.github/config/rubocop_linter_action.yml +4 -4
  3. data/.github/workflows/ci.yml +12 -12
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +83 -7
  6. data/CHANGELOG.md +38 -0
  7. data/CLAUDE.md +139 -0
  8. data/Gemfile +16 -11
  9. data/Gemfile.lock +53 -27
  10. data/README.md +84 -21
  11. data/docs/arguments.md +290 -0
  12. data/docs/best-practices.md +153 -0
  13. data/docs/callbacks.md +476 -0
  14. data/docs/concepts.md +80 -0
  15. data/docs/configuration.md +204 -0
  16. data/docs/context.md +128 -0
  17. data/docs/crud.md +525 -0
  18. data/docs/errors.md +280 -0
  19. data/docs/generators.md +250 -0
  20. data/docs/outputs.md +158 -0
  21. data/docs/pundit-authorization.md +320 -0
  22. data/docs/quickstart.md +134 -0
  23. data/docs/readme.md +101 -0
  24. data/docs/recipes.md +14 -0
  25. data/docs/rubocop.md +285 -0
  26. data/docs/ruby-lsp.md +133 -0
  27. data/docs/service-rendering.md +222 -0
  28. data/docs/steps.md +391 -0
  29. data/docs/summary.md +21 -0
  30. data/docs/testing.md +549 -0
  31. data/lib/generators/light_services/install/USAGE +15 -0
  32. data/lib/generators/light_services/install/install_generator.rb +41 -0
  33. data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
  34. data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
  35. data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
  36. data/lib/generators/light_services/service/USAGE +21 -0
  37. data/lib/generators/light_services/service/service_generator.rb +68 -0
  38. data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
  39. data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
  40. data/lib/light/services/base.rb +134 -122
  41. data/lib/light/services/base_with_context.rb +23 -1
  42. data/lib/light/services/callbacks.rb +157 -0
  43. data/lib/light/services/collection.rb +145 -0
  44. data/lib/light/services/concerns/execution.rb +79 -0
  45. data/lib/light/services/concerns/parent_service.rb +34 -0
  46. data/lib/light/services/concerns/state_management.rb +30 -0
  47. data/lib/light/services/config.rb +82 -16
  48. data/lib/light/services/constants.rb +100 -0
  49. data/lib/light/services/dsl/arguments_dsl.rb +85 -0
  50. data/lib/light/services/dsl/outputs_dsl.rb +81 -0
  51. data/lib/light/services/dsl/steps_dsl.rb +205 -0
  52. data/lib/light/services/dsl/validation.rb +162 -0
  53. data/lib/light/services/exceptions.rb +25 -2
  54. data/lib/light/services/message.rb +28 -3
  55. data/lib/light/services/messages.rb +92 -32
  56. data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
  57. data/lib/light/services/rspec/matchers/define_output.rb +147 -0
  58. data/lib/light/services/rspec/matchers/define_step.rb +225 -0
  59. data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
  60. data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
  61. data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
  62. data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
  63. data/lib/light/services/rspec.rb +15 -0
  64. data/lib/light/services/rubocop/cop/light_services/argument_type_required.rb +52 -0
  65. data/lib/light/services/rubocop/cop/light_services/condition_method_exists.rb +173 -0
  66. data/lib/light/services/rubocop/cop/light_services/deprecated_methods.rb +113 -0
  67. data/lib/light/services/rubocop/cop/light_services/dsl_order.rb +176 -0
  68. data/lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb +102 -0
  69. data/lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb +66 -0
  70. data/lib/light/services/rubocop/cop/light_services/output_type_required.rb +52 -0
  71. data/lib/light/services/rubocop/cop/light_services/step_method_exists.rb +109 -0
  72. data/lib/light/services/rubocop.rb +12 -0
  73. data/lib/light/services/settings/field.rb +114 -0
  74. data/lib/light/services/settings/step.rb +53 -20
  75. data/lib/light/services/utils.rb +38 -0
  76. data/lib/light/services/version.rb +1 -1
  77. data/lib/light/services.rb +2 -0
  78. data/lib/ruby_lsp/light_services/addon.rb +36 -0
  79. data/lib/ruby_lsp/light_services/definition.rb +132 -0
  80. data/lib/ruby_lsp/light_services/indexing_enhancement.rb +263 -0
  81. data/light-services.gemspec +6 -8
  82. metadata +68 -26
  83. data/lib/light/services/class_based_collection/base.rb +0 -86
  84. data/lib/light/services/class_based_collection/mount.rb +0 -33
  85. data/lib/light/services/collection/arguments.rb +0 -34
  86. data/lib/light/services/collection/base.rb +0 -59
  87. data/lib/light/services/collection/outputs.rb +0 -16
  88. data/lib/light/services/settings/argument.rb +0 -68
  89. data/lib/light/services/settings/output.rb +0 -34
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)