servus 0.2.1 → 0.4.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/servus/event_handler/event_handler_generator.rb +1 -1
  3. data/lib/generators/servus/guard/guard_generator.rb +1 -1
  4. data/lib/generators/servus/guard/templates/guard.rb.erb +5 -3
  5. data/lib/generators/servus/service/service_generator.rb +1 -1
  6. data/lib/servus/base.rb +67 -9
  7. data/lib/servus/config.rb +71 -3
  8. data/lib/servus/events/bus.rb +29 -0
  9. data/lib/servus/events/emitter.rb +15 -0
  10. data/lib/servus/extensions/lazily/call.rb +82 -0
  11. data/lib/servus/extensions/lazily/errors.rb +37 -0
  12. data/lib/servus/extensions/lazily/ext.rb +23 -0
  13. data/lib/servus/extensions/lazily/resolver.rb +32 -0
  14. data/lib/servus/guard.rb +7 -6
  15. data/lib/servus/guards/falsey_guard.rb +3 -3
  16. data/lib/servus/guards/presence_guard.rb +4 -4
  17. data/lib/servus/guards/state_guard.rb +4 -5
  18. data/lib/servus/guards/truthy_guard.rb +3 -3
  19. data/lib/servus/helpers/controller_helpers.rb +40 -0
  20. data/lib/servus/railtie.rb +7 -1
  21. data/lib/servus/support/data_object.rb +80 -0
  22. data/lib/servus/support/errors.rb +16 -0
  23. data/lib/servus/support/lockdown.rb +94 -0
  24. data/lib/servus/support/logger.rb +16 -0
  25. data/lib/servus/support/response.rb +12 -1
  26. data/lib/servus/support/validator.rb +79 -34
  27. data/lib/servus/testing/example_builders.rb +74 -0
  28. data/lib/servus/testing/matchers.rb +99 -0
  29. data/lib/servus/version.rb +1 -1
  30. data/lib/servus.rb +2 -0
  31. metadata +16 -114
  32. data/.claude/commands/check-docs.md +0 -1
  33. data/.claude/commands/consistency-check.md +0 -1
  34. data/.claude/commands/fine-tooth-comb.md +0 -1
  35. data/.claude/commands/red-green-refactor.md +0 -5
  36. data/.claude/settings.json +0 -24
  37. data/.rspec +0 -3
  38. data/.rubocop.yml +0 -27
  39. data/.yardopts +0 -6
  40. data/CHANGELOG.md +0 -122
  41. data/CLAUDE.md +0 -10
  42. data/IDEAS.md +0 -5
  43. data/LICENSE.txt +0 -21
  44. data/READme.md +0 -856
  45. data/Rakefile +0 -45
  46. data/docs/core/1_overview.md +0 -77
  47. data/docs/core/2_architecture.md +0 -120
  48. data/docs/core/3_service_objects.md +0 -121
  49. data/docs/features/1_schema_validation.md +0 -119
  50. data/docs/features/2_error_handling.md +0 -121
  51. data/docs/features/3_async_execution.md +0 -81
  52. data/docs/features/4_logging.md +0 -64
  53. data/docs/features/5_event_bus.md +0 -244
  54. data/docs/features/6_guards.md +0 -356
  55. data/docs/features/guards_naming_convention.md +0 -540
  56. data/docs/guides/1_common_patterns.md +0 -90
  57. data/docs/guides/2_migration_guide.md +0 -175
  58. data/docs/integration/1_configuration.md +0 -154
  59. data/docs/integration/2_testing.md +0 -287
  60. data/docs/integration/3_rails_integration.md +0 -99
  61. data/docs/yard/Servus/Base.html +0 -1645
  62. data/docs/yard/Servus/Config.html +0 -582
  63. data/docs/yard/Servus/Extensions/Async/Call.html +0 -400
  64. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +0 -140
  65. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +0 -154
  66. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +0 -154
  67. data/docs/yard/Servus/Extensions/Async/Errors.html +0 -128
  68. data/docs/yard/Servus/Extensions/Async/Ext.html +0 -119
  69. data/docs/yard/Servus/Extensions/Async/Job.html +0 -310
  70. data/docs/yard/Servus/Extensions/Async.html +0 -141
  71. data/docs/yard/Servus/Extensions.html +0 -117
  72. data/docs/yard/Servus/Generators/ServiceGenerator.html +0 -261
  73. data/docs/yard/Servus/Generators.html +0 -115
  74. data/docs/yard/Servus/Helpers/ControllerHelpers.html +0 -457
  75. data/docs/yard/Servus/Helpers.html +0 -115
  76. data/docs/yard/Servus/Railtie.html +0 -134
  77. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +0 -287
  78. data/docs/yard/Servus/Support/Errors/BadRequestError.html +0 -283
  79. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +0 -284
  80. data/docs/yard/Servus/Support/Errors/InternalServerError.html +0 -283
  81. data/docs/yard/Servus/Support/Errors/NotFoundError.html +0 -284
  82. data/docs/yard/Servus/Support/Errors/ServiceError.html +0 -489
  83. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +0 -290
  84. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +0 -200
  85. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +0 -288
  86. data/docs/yard/Servus/Support/Errors/ValidationError.html +0 -200
  87. data/docs/yard/Servus/Support/Errors.html +0 -140
  88. data/docs/yard/Servus/Support/Logger.html +0 -856
  89. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +0 -585
  90. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +0 -257
  91. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +0 -343
  92. data/docs/yard/Servus/Support/Rescuer.html +0 -267
  93. data/docs/yard/Servus/Support/Response.html +0 -574
  94. data/docs/yard/Servus/Support/Validator.html +0 -1150
  95. data/docs/yard/Servus/Support.html +0 -119
  96. data/docs/yard/Servus/Testing/ExampleBuilders.html +0 -523
  97. data/docs/yard/Servus/Testing/ExampleExtractor.html +0 -578
  98. data/docs/yard/Servus/Testing.html +0 -142
  99. data/docs/yard/Servus.html +0 -343
  100. data/docs/yard/_index.html +0 -535
  101. data/docs/yard/class_list.html +0 -54
  102. data/docs/yard/css/common.css +0 -1
  103. data/docs/yard/css/full_list.css +0 -58
  104. data/docs/yard/css/style.css +0 -503
  105. data/docs/yard/file.1_common_patterns.html +0 -154
  106. data/docs/yard/file.1_configuration.html +0 -115
  107. data/docs/yard/file.1_overview.html +0 -142
  108. data/docs/yard/file.1_schema_validation.html +0 -188
  109. data/docs/yard/file.2_architecture.html +0 -157
  110. data/docs/yard/file.2_error_handling.html +0 -190
  111. data/docs/yard/file.2_migration_guide.html +0 -242
  112. data/docs/yard/file.2_testing.html +0 -227
  113. data/docs/yard/file.3_async_execution.html +0 -145
  114. data/docs/yard/file.3_rails_integration.html +0 -160
  115. data/docs/yard/file.3_service_objects.html +0 -191
  116. data/docs/yard/file.4_logging.html +0 -135
  117. data/docs/yard/file.ErrorHandling.html +0 -190
  118. data/docs/yard/file.READme.html +0 -674
  119. data/docs/yard/file.architecture.html +0 -157
  120. data/docs/yard/file.async_execution.html +0 -145
  121. data/docs/yard/file.common_patterns.html +0 -154
  122. data/docs/yard/file.configuration.html +0 -115
  123. data/docs/yard/file.error_handling.html +0 -190
  124. data/docs/yard/file.logging.html +0 -135
  125. data/docs/yard/file.migration_guide.html +0 -242
  126. data/docs/yard/file.overview.html +0 -142
  127. data/docs/yard/file.rails_integration.html +0 -160
  128. data/docs/yard/file.schema_validation.html +0 -188
  129. data/docs/yard/file.service_objects.html +0 -191
  130. data/docs/yard/file.testing.html +0 -227
  131. data/docs/yard/file_list.html +0 -119
  132. data/docs/yard/frames.html +0 -22
  133. data/docs/yard/index.html +0 -674
  134. data/docs/yard/js/app.js +0 -344
  135. data/docs/yard/js/full_list.js +0 -242
  136. data/docs/yard/js/jquery.js +0 -4
  137. data/docs/yard/method_list.html +0 -542
  138. data/docs/yard/top-level-namespace.html +0 -110
data/READme.md DELETED
@@ -1,856 +0,0 @@
1
- ## Servus Gem
2
-
3
-
4
- Servus is a gem for creating and managing service objects. It includes:
5
-
6
- - A base class for service objects
7
- - Generators for core service objects and specs
8
- - Support for schema validation
9
- - Support for error handling
10
- - Support for logging
11
- - Event-driven architecture with EventHandlers
12
-
13
- 👉🏽 [View the docs](https://zarpay.github.io/servus/)
14
-
15
- ## Generators
16
-
17
- Service objects can be easily created using the `rails g servus:service namespace/service_name [*params]` command. For sake of consistency, use this command when generating new service objects.
18
-
19
- ### Generate Service
20
-
21
- ```bash
22
- $ rails g servus:service namespace/do_something_helpful user
23
- => create app/services/namespace/do_something_helpful/service.rb
24
- create spec/services/namespace/do_something_helpful/service_spec.rb
25
- create app/schemas/services/namespace/do_something_helpful/result.json
26
- create app/schemas/services/namespace/do_something_helpful/arguments.json
27
- ```
28
-
29
- ### Destroy Service
30
-
31
- ```bash
32
- $ rails d servus:service namespace/do_something_helpful
33
- => remove app/services/namespace/do_something_helpful/service.rb
34
- remove spec/services/namespace/do_something_helpful/service_spec.rb
35
- remove app/schemas/services/namespace/do_something_helpful/result.json
36
- remove app/schemas/services/namespace/do_something_helpful/arguments.json
37
- ```
38
-
39
- ## Arguments
40
-
41
- Service objects should use keyword arguments rather than positional arguments for improved clarity and more meaningful error messages.
42
-
43
- ```ruby
44
- # Good ✅
45
- class Services::ProcessPayment::Service < Servus::Base
46
- def initialize(user:, amount:, payment_method:)
47
- @user = user
48
- @amount = amount
49
- @payment_method = payment_method
50
- end
51
- end
52
-
53
- # Bad ❌
54
- class Services::ProcessPayment::Service < Servus::Base
55
- def initialize(user, amount, payment_method)
56
- @user = user
57
- @amount = amount
58
- @payment_method = payment_method
59
- end
60
- end
61
- ```
62
-
63
- ## Directory Structure
64
-
65
- Each service belongs in its own namespace with this structure:
66
-
67
- - `app/services/service_name/service.rb` - Main class/entry point
68
- - `app/services/service_name/support/` - Service-specific supporting classes
69
-
70
- Supporting classes should never be used outside their parent service.
71
-
72
- ```
73
- app/services/
74
- ├── process_payment/
75
- │ ├── service.rb
76
- │ └── support/
77
- │ ├── payment_validator.rb
78
- │ └── receipt_generator.rb
79
- ├── generate_report/
80
- │ ├── service.rb
81
- │ └── support/
82
- │ ├── report_formatter.rb
83
- │ └── data_collector.rb
84
- ```
85
-
86
- ## **Methods**
87
-
88
- Every service object must implement:
89
-
90
- - An `initialize` method that sets instance variables
91
- - A parameter-less `call` instance method that executes the service logic
92
-
93
- ```ruby
94
- class Services::GenerateReport::Service < Servus::Base
95
- def initialize(user:, report_type:, date_range:)
96
- @user = user
97
- @report_type = report_type
98
- @date_range = date_range
99
- end
100
-
101
- def call
102
- data = collect_data
103
- if data.empty?
104
- return failure("No data available for the selected date range")
105
- end
106
-
107
- formatted_report = format_report(data)
108
- success(formatted_report)
109
- end
110
-
111
- private
112
-
113
- def collect_data
114
- # Implementation details...
115
- end
116
-
117
- def format_report(data)
118
- # Implementation details...
119
- end
120
- end
121
-
122
- ```
123
-
124
- ## **Asynchronous Execution**
125
-
126
- You can asynchronously execute any service class that inherits from `Servus::Base` using `.call_async`. This uses `ActiveJob` under the hood and supports standard job options (`wait`, `queue`, `priority`, etc.). Only available in environments where `ActiveJob` is loaded (e.g., Rails apps)
127
-
128
- ```ruby
129
- # Good ✅
130
- Services::NotifyUser::Service.call_async(
131
- user_id: current_user.id,
132
- wait: 5.minutes,
133
- queue: :low_priority,
134
- job_options: { tags: ['notifications'] }
135
- )
136
-
137
- # Bad ❌
138
- Services::NotifyUser::Support::MessageBuilder.call_async(
139
- # Invalid: support classes don't inherit from Servus::Base
140
- )
141
- ```
142
-
143
- ## **Inheritance**
144
-
145
- - Every main service class (`service.rb`) must inherit from `Servus::Base`
146
- - Supporting classes should NOT inherit from `Servus::Base`
147
-
148
- ```ruby
149
- # Good ✅
150
- class Services::NotifyUser::Service < Servus::Base
151
- # Service implementation
152
- end
153
-
154
- class Services::NotifyUser::Support::MessageBuilder
155
- # Support class implementation (does NOT inherit from BaseService)
156
- end
157
-
158
- # Bad ❌
159
- class Services::NotifyUser::Support::MessageBuilder < Servus::Base
160
- # Incorrect: support classes should not inherit from Base class
161
- end
162
- ```
163
-
164
- ## **Call Chain**
165
-
166
- Always use the class method `call` instead of manual instantiation. The `call` method:
167
-
168
- 1. Initializes an instance of the service using provided keyword arguments
169
- 2. Calls the instance-level `call` method
170
- 3. Handles schema validation of inputs and outputs
171
- 4. Handles logging of inputs and results
172
- 5. Automatically benchmarks execution time for performance monitoring
173
-
174
- ```ruby
175
- # Good ✅
176
- result = Services::ProcessPayment::Service.call(
177
- amount: 50,
178
- user_id: 123,
179
- payment_method: "credit_card"
180
- )
181
-
182
- # Bad ❌ - bypasses logging and other class-level functionality
183
- service = Services::ProcessPayment::Service.new(
184
- amount: 50,
185
- user_id: 123,
186
- payment_method: "credit_card"
187
- )
188
- result = service.call
189
-
190
- ```
191
-
192
- When services call other services, always use the class-level `call` method:
193
-
194
- ```ruby
195
- def process_order
196
- # Good ✅
197
- payment_result = Services::ProcessPayment::Service.call(
198
- amount: @order.total,
199
- payment_method: @payment_details
200
- )
201
-
202
- # Bad ❌
203
- payment_service = Services::ProcessPayment::Service.new(
204
- amount: @order.total,
205
- payment_method: @payment_details
206
- )
207
- payment_result = payment_service.call
208
- end
209
-
210
- ```
211
-
212
- ## **Responses**
213
-
214
- The `Servus::Base` provides standardized response methods:
215
-
216
- - `success(data)` - Returns success with data as a single argument
217
- - `failure(message, **options)` - Logs error and returns failure response
218
- - `error!(message)` - Logs error and raises exception
219
-
220
- ```ruby
221
- def call
222
- # Return failure with message
223
- return failure("Order is not in a pending state") unless @order.pending?
224
-
225
- # Do something important
226
-
227
- # Process and return success with single data object
228
- success({
229
- order_id: @order.id,
230
- status: "processed",
231
- timestamp: Time.now
232
- })
233
- end
234
- ```
235
-
236
- All responses are `Servus::Support::Response` objects with a `success?` boolean attribute and either `data` (for success) or `error` (for error) attributes.
237
-
238
- ### Service Error Returns and Handling
239
-
240
- By default, the `failure(...)` method creates an instance of `ServiceError` and adds it to the response type's `error` attribute. Standard and custom error types should inherit from the `ServiceError` class and optionally implement a custom `api_error` method. This enables developers to choose between using an API-specific error or generic error message in the calling context.
241
-
242
- ```ruby
243
- # Called from within a Service Object
244
- class SomeServiceObject::Service < Servus::Base
245
- def call
246
- # Return default ServiceError with custom message
247
- failure("That didn't work for some reason")
248
- #=> Response(false, nil, Servus::Support::Errors::ServiceError("That didn't work for some reason"))
249
- #
250
- # OR
251
- #
252
- # Specify ServiceError type with custom message
253
- failure("Custom message", type: Servus::Support::Errors::NotFoundError)
254
- #=> Response(false, nil, Servus::Support::Errors::NotFoundError("Custom message"))
255
- #
256
- # OR
257
- #
258
- # Specify ServiceError type with default message
259
- failure(type: Servus::Support::Errors::NotFoundError)
260
- #=> Response(false, nil, Servus::Support::Errors::NotFoundError("Not found"))
261
- #
262
- # OR
263
- #
264
- # Accept all defaults
265
- failure
266
- #=> Response(false, nil, Servus::Support::Errors::ServiceError("An error occurred"))
267
- end
268
- end
269
-
270
- # Error handling in parent context
271
- class SomeController < AppController
272
- def controller_action
273
- result = SomeServiceObject::Service.call(arg: 1)
274
-
275
- return if result.success?
276
-
277
- # If you just want the error message
278
- bad_request(result.error.message)
279
-
280
- # If you want the API error
281
- service_object_error(result.error.api_error)
282
- end
283
- end
284
- ```
285
-
286
- ### `rescue_from` for service errors
287
-
288
- Services can configure default error handling using the `rescue_from` method.
289
-
290
- ```ruby
291
- class SomeServiceObject::Service < Servus::Base
292
- class SomethingBroke < StandardError; end
293
- class SomethingGlitched < StandardError; end
294
-
295
- # Rescue from standard errors and use custom error
296
- rescue_from
297
- SomethingBroke,
298
- SomethingGlitched,
299
- use: Servus::Support::Errors::ServiceUnavailableError # this is optional
300
-
301
- def call
302
- do_something
303
- end
304
-
305
- private
306
-
307
- def do_something
308
- make_and_api_call
309
- rescue Net::HTTPError => e
310
- raise SomethingGlitched, "Whoaaaa, something went wrong! #{e.message}"
311
- end
312
- end
313
- end
314
- ```
315
-
316
- ```sh
317
- result = SomeServiceObject::Service.call
318
- # Failure response
319
- result.error.class
320
- => Servus::Support::Errors::ServiceUnavailableError
321
- result.error.message
322
- => "[SomeServiceObject::Service::SomethingGlitched]: Whoaaaa, something went wrong! Net::HTTPError (503)"
323
- result.error.api_error
324
- => { code: :service_unavailable, message: "[SomeServiceObject::Service::SomethingGlitched]: Whoaaaa, something went wrong! Net::HTTPError (503)" }
325
- ```
326
-
327
- The `rescue_from` method will rescue from the specified errors and use the specified error type to create a failure response object with
328
- the custom error. It helps eliminate the need to manually rescue many errors and create failure responses within the call method of
329
- a service object.
330
-
331
- You can also provide a block for custom error handling:
332
-
333
- ```ruby
334
- class SomeServiceObject::Service < Servus::Base
335
- # Custom error handling with a block
336
- rescue_from ActiveRecord::RecordInvalid do |exception|
337
- failure("Validation failed: #{exception.message}", type: ValidationError)
338
- end
339
-
340
- rescue_from Net::HTTPError do |exception|
341
- # Can even return success to recover from errors
342
- success(recovered: true, error_message: exception.message)
343
- end
344
-
345
- def call
346
- # Service logic
347
- end
348
- end
349
- ```
350
-
351
- The block receives the exception and has access to `success` and `failure` methods for creating the response.
352
-
353
- ## **Guards**
354
-
355
- Guards are reusable validation rules that halt service execution when conditions aren't met. They provide declarative precondition checking with rich error responses.
356
-
357
- ### Built-in Guards
358
-
359
- ```ruby
360
- def call
361
- # Validate values are present (not nil or empty)
362
- enforce_presence!(user: user, account: account)
363
-
364
- # Validate object attributes are truthy
365
- enforce_truthy!(on: user, check: :active)
366
- enforce_truthy!(on: user, check: [:active, :verified]) # all must be truthy
367
-
368
- # Validate object attributes are falsey
369
- enforce_falsey!(on: user, check: :banned)
370
- enforce_falsey!(on: post, check: [:deleted, :hidden]) # all must be falsey
371
-
372
- # Validate attribute matches expected value(s)
373
- enforce_state!(on: order, check: :status, is: :pending)
374
- enforce_state!(on: account, check: :status, is: [:active, :trial]) # any match passes
375
-
376
- # ... business logic ...
377
- success(result)
378
- end
379
- ```
380
-
381
- ### Predicate Methods
382
-
383
- Each guard has a predicate version for conditional logic:
384
-
385
- ```ruby
386
- if check_truthy?(on: user, check: :premium)
387
- apply_premium_discount
388
- else
389
- apply_standard_rate
390
- end
391
- ```
392
-
393
- ### Custom Guards
394
-
395
- Create custom guards in `app/guards/`:
396
-
397
- ```bash
398
- $ rails g servus:guard open_account
399
- => create app/guards/open_account_guard.rb
400
- create spec/guards/open_account_guard_spec.rb
401
- ```
402
-
403
- ```ruby
404
- # app/guards/open_account_guard.rb
405
- class OpenAccountGuard < Servus::Guard
406
- http_status 422
407
- error_code 'open_account_required'
408
-
409
- message 'Invalid account: %<name> does not have an open account' do
410
- message_data
411
- end
412
-
413
- def test(user:)
414
- user.account.present? && user.account.status_open?
415
- end
416
-
417
- private
418
-
419
- def message_data
420
- {
421
- name: kwargs[:user].name
422
- }
423
- end
424
- end
425
-
426
- # Usage in services:
427
- # enforce_open_account!(user: user_record) # throws on failure
428
- # check_open_account?(user: user_record) # returns boolean
429
- ```
430
-
431
- ### Guard Error Responses
432
-
433
- When a guard fails, the service returns a failure response with structured error data:
434
-
435
- ```ruby
436
- result = TransferService.call(from_account: account, amount: 1000)
437
- result.success? # => false
438
- result.error.message # => "Invalid account: Bob Jones does not have an open account"
439
- result.error.code # => "open_account_required"
440
- result.error.http_status # => 422
441
- ```
442
-
443
- ## Controller Helpers
444
-
445
- Service objects can be called from controllers using the `run_service` and `render_service_error` helpers.
446
-
447
- ### run_service
448
-
449
- `run_service` calls the service object with the provided parameters and sets an instance variable `@result` to the
450
- result of the service object. If the result is not successful, it automatically calls `render_service_error` with
451
- the error. This provides consistent error handling across controllers.
452
-
453
- ```ruby
454
- class SomeController < AppController
455
- # Before
456
- def controller_action
457
- result = Services::SomeServiceObject::Service.call(my_params)
458
- return if result.success?
459
- render_service_error(result.error)
460
- end
461
-
462
- # After
463
- def controller_action_refactored
464
- run_service Services::SomeServiceObject::Service, my_params
465
- end
466
- end
467
- ```
468
-
469
- ### render_service_error
470
-
471
- `render_service_error` renders a service error as JSON. It takes an error object (not a hash) and uses
472
- `error.http_status` for the response status and `error.api_error` for the response body.
473
-
474
- ```ruby
475
- # Behind the scenes, render_service_error calls the following:
476
- #
477
- # render json: { error: error.api_error }, status: error.http_status
478
- #
479
- # Which produces a response like:
480
- # { "error": { "code": "not_found", "message": "User not found" } }
481
- # with HTTP status 404
482
-
483
- class SomeController < AppController
484
- def controller_action
485
- result = Services::SomeServiceObject::Service.call(my_params)
486
- return if result.success?
487
-
488
- render_service_error(result.error)
489
- end
490
- end
491
- ```
492
-
493
- Override `render_service_error` in your controller to customize error response format:
494
-
495
- ```ruby
496
- class ApplicationController < ActionController::Base
497
- def render_service_error(error)
498
- render json: {
499
- error: {
500
- type: error.api_error[:code],
501
- details: error.message,
502
- timestamp: Time.current
503
- }
504
- }, status: error.http_status
505
- end
506
- end
507
- ```
508
-
509
- ## **Schema Validation**
510
-
511
- Service objects support two methods for schema validation: JSON Schema files and inline schema declarations.
512
-
513
- ### 1. File-based Schema Validation
514
-
515
- Every service can have corresponding schema files in the centralized schema directory:
516
-
517
- - `app/schemas/services/service_name/arguments.json` - Validates input arguments
518
- - `app/schemas/services/service_name/result.json` - Validates success response data
519
-
520
- Example `arguments.json`:
521
-
522
- ```json
523
- {
524
- "type": "object",
525
- "required": ["user_id", "amount", "payment_method"],
526
- "properties": {
527
- "user_id": { "type": "integer" },
528
- "amount": {
529
- "type": "integer",
530
- "minimum": 1
531
- },
532
- "payment_method": {
533
- "type": "string",
534
- "enum": ["credit_card", "paypal", "bank_transfer"]
535
- },
536
- "currency": {
537
- "type": "string",
538
- "default": "USD"
539
- }
540
- },
541
- "additionalProperties": false
542
- }
543
-
544
- ```
545
-
546
- Example `result.json`:
547
-
548
- ```json
549
- {
550
- "type": "object",
551
- "required": ["transaction_id", "status"],
552
- "properties": {
553
- "transaction_id": { "type": "string" },
554
- "status": {
555
- "type": "string",
556
- "enum": ["approved", "pending", "declined"]
557
- },
558
- "receipt_url": { "type": "string" }
559
- }
560
- }
561
-
562
- ```
563
-
564
- ### 2. Inline Schema Validation
565
-
566
- Schemas can be declared directly within the service class using the `schema` DSL method:
567
-
568
- ```ruby
569
- class Services::ProcessPayment::Service < Servus::Base
570
- schema(
571
- arguments: {
572
- type: "object",
573
- required: ["user_id", "amount", "payment_method"],
574
- properties: {
575
- user_id: { type: "integer" },
576
- amount: {
577
- type: "integer",
578
- minimum: 1
579
- },
580
- payment_method: {
581
- type: "string",
582
- enum: ["credit_card", "paypal", "bank_transfer"]
583
- },
584
- currency: {
585
- type: "string",
586
- default: "USD"
587
- }
588
- },
589
- additionalProperties: false
590
- },
591
- result: {
592
- type: "object",
593
- required: ["transaction_id", "status"],
594
- properties: {
595
- transaction_id: { type: "string" },
596
- status: {
597
- type: "string",
598
- enum: ["approved", "pending", "declined"]
599
- },
600
- receipt_url: { type: "string" }
601
- }
602
- }
603
- )
604
-
605
- def initialize(user_id:, amount:, payment_method:, currency: 'USD')
606
- @user_id = user_id
607
- @amount = amount
608
- @payment_method = payment_method
609
- @currency = currency
610
- end
611
-
612
- def call
613
- # Service logic...
614
- success({
615
- transaction_id: "txn_1",
616
- status: "approved"
617
- })
618
- end
619
- end
620
- ```
621
-
622
- ---
623
-
624
- These schemas use JSON Schema format to enforce type safety and input/output contracts. For detailed information on authoring JSON Schema files, refer to the official specification at: https://json-schema.org/specification.html
625
-
626
- ### Schema Resolution
627
-
628
- The validation system follows this precedence:
629
-
630
- 1. Schemas defined via `schema` DSL method (recommended)
631
- 2. Inline schema constants (`ARGUMENTS_SCHEMA` or `RESULT_SCHEMA`) - legacy support
632
- 3. JSON files in schema_root directory - legacy support
633
- 4. Returns nil if no schema is found (validation is opt-in)
634
-
635
- ### Schema Caching
636
-
637
- Both file-based and inline schemas are automatically cached:
638
-
639
- - First validation request loads and caches the schema
640
- - Subsequent validations use the cached version
641
- - Cache can be cleared using `Servus::Support::Validator.clear_cache!`
642
-
643
- ## **Logging**
644
-
645
- Servus automatically logs service execution details, making it easy to track and debug service calls.
646
-
647
- ### Automatic Logging
648
-
649
- Every service call automatically logs:
650
-
651
- - **Service invocation** with input arguments
652
- - **Success results** with execution duration
653
- - **Failure results** with error details and duration
654
- - **Validation errors** for schema violations
655
- - **Uncaught exceptions** with error messages
656
-
657
- ### Logger Configuration
658
-
659
- The logger automatically adapts to your environment:
660
-
661
- - **Rails applications**: Uses `Rails.logger`
662
- - **Non-Rails applications**: Uses stdout logger
663
-
664
- ### Log Output Examples
665
-
666
- ```ruby
667
- # Success
668
- INFO -- : Calling Services::ProcessPayment::Service with args: {:user_id=>123, :amount=>50}
669
- INFO -- : Services::ProcessPayment::Service succeeded in 0.245s
670
-
671
- # Failure
672
- INFO -- : Calling Services::ProcessPayment::Service with args: {:user_id=>123, :amount=>50}
673
- WARN -- : Services::ProcessPayment::Service failed in 0.156s with error: Insufficient funds
674
-
675
- # Validation Error
676
- ERROR -- : Services::ProcessPayment::Service validation error: The property '#/amount' value -10 was less than minimum value 1
677
-
678
- # Exception
679
- ERROR -- : Services::ProcessPayment::Service uncaught exception: NoMethodError - undefined method 'charge' for nil:NilClass
680
- ```
681
-
682
- All logging happens transparently when using the class-level `.call` method. This is one of the reasons why direct instantiation (bypassing `.call`) is discouraged.
683
-
684
- ## **Configuration**
685
-
686
- Servus can be configured to customize behavior for your application needs.
687
-
688
- ### Schema Root Directory
689
-
690
- By default, Servus looks for schema files in `app/schemas/services/`. You can customize this location:
691
-
692
- ```ruby
693
- # config/initializers/servus.rb
694
- Servus.configure do |config|
695
- config.schema_root = Rails.root.join('lib/schemas')
696
- end
697
- ```
698
-
699
- ### Default Behavior
700
-
701
- Without explicit configuration:
702
-
703
- - **Rails applications**: Schema root defaults to `Rails.root/app/schemas/services`
704
- - **Non-Rails applications**: Schema root defaults to `./app/schemas/services` relative to the gem installation
705
-
706
- The configuration is accessed through the singleton `Servus.config` instance and can be modified using `Servus.configure`.
707
-
708
- ## **Event Bus**
709
-
710
- Servus includes an event-driven architecture for decoupling service logic from side effects. Services emit events, and EventHandlers subscribe to them and invoke downstream services.
711
-
712
- ### Emitting Events from Services
713
-
714
- Services can declare events that are emitted on success or failure:
715
-
716
- ```ruby
717
- class CreateUser::Service < Servus::Base
718
- emits :user_created, on: :success
719
- emits :user_creation_failed, on: :failure
720
-
721
- def initialize(email:, name:)
722
- @email = email
723
- @name = name
724
- end
725
-
726
- def call
727
- user = User.create!(email: @email, name: @name)
728
- success(user: user)
729
- rescue ActiveRecord::RecordInvalid => e
730
- failure(e.message)
731
- end
732
- end
733
- ```
734
-
735
- Custom payloads can be provided via blocks or method references:
736
-
737
- ```ruby
738
- emits :user_created, on: :success do |result|
739
- { user_id: result.data[:user].id, email: result.data[:user].email }
740
- end
741
- ```
742
-
743
- ### Event Handlers
744
-
745
- EventHandlers subscribe to events and invoke services in response. They live in `app/events/`:
746
-
747
- ```ruby
748
- # app/events/user_created_handler.rb
749
- class UserCreatedHandler < Servus::EventHandler
750
- handles :user_created
751
-
752
- invoke SendWelcomeEmail::Service, async: true do |payload|
753
- { user_id: payload[:user_id], email: payload[:email] }
754
- end
755
-
756
- invoke TrackAnalytics::Service, async: true do |payload|
757
- { event: 'user_created', user_id: payload[:user_id] }
758
- end
759
- end
760
- ```
761
-
762
- ### Generate Event Handler
763
-
764
- ```bash
765
- $ rails g servus:event_handler user_created
766
- => create app/events/user_created_handler.rb
767
- create spec/events/user_created_handler_spec.rb
768
- ```
769
-
770
- ### Invocation Options
771
-
772
- ```ruby
773
- # Synchronous (default)
774
- invoke NotifyAdmin::Service do |payload|
775
- { message: "New user: #{payload[:email]}" }
776
- end
777
-
778
- # Async via ActiveJob
779
- invoke SendEmail::Service, async: true do |payload|
780
- { user_id: payload[:user_id] }
781
- end
782
-
783
- # Async with specific queue
784
- invoke SendEmail::Service, async: true, queue: :mailers do |payload|
785
- { user_id: payload[:user_id] }
786
- end
787
-
788
- # Conditional invocation
789
- invoke GrantRewards::Service, if: ->(p) { p[:premium] } do |payload|
790
- { user_id: payload[:user_id] }
791
- end
792
- ```
793
-
794
- ### Emitting Events Directly
795
-
796
- EventHandlers provide an `emit` class method for emitting events from controllers, jobs, or other code:
797
-
798
- ```ruby
799
- class UsersController < ApplicationController
800
- def create
801
- user = User.create!(user_params)
802
- UserCreatedHandler.emit({ user_id: user.id, email: user.email })
803
- redirect_to user
804
- end
805
- end
806
- ```
807
-
808
- ### Payload Schema Validation
809
-
810
- Define JSON schemas to validate event payloads:
811
-
812
- ```ruby
813
- class UserCreatedHandler < Servus::EventHandler
814
- handles :user_created
815
-
816
- schema payload: {
817
- type: 'object',
818
- required: ['user_id', 'email'],
819
- properties: {
820
- user_id: { type: 'integer' },
821
- email: { type: 'string', format: 'email' }
822
- }
823
- }
824
-
825
- invoke SendWelcomeEmail::Service, async: true do |payload|
826
- { user_id: payload[:user_id], email: payload[:email] }
827
- end
828
- end
829
- ```
830
-
831
- ### Testing Events
832
-
833
- Servus provides RSpec matchers for testing events:
834
-
835
- ```ruby
836
- # Test that a service emits an event
837
- it 'emits user_created event' do
838
- expect {
839
- CreateUser::Service.call(email: 'test@example.com', name: 'Test')
840
- }.to emit_event(:user_created)
841
- end
842
-
843
- # Test payload content
844
- it 'emits event with expected payload' do
845
- expect {
846
- CreateUser::Service.call(email: 'test@example.com', name: 'Test')
847
- }.to emit_event(:user_created).with(hash_including(email: 'test@example.com'))
848
- end
849
-
850
- # Test handler invokes service
851
- it 'invokes SendWelcomeEmail' do
852
- expect {
853
- UserCreatedHandler.handle(payload)
854
- }.to call_service(SendWelcomeEmail::Service).with(user_id: 123)
855
- end
856
- ```