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.
- checksums.yaml +4 -4
- data/lib/generators/servus/event_handler/event_handler_generator.rb +1 -1
- data/lib/generators/servus/guard/guard_generator.rb +1 -1
- data/lib/generators/servus/guard/templates/guard.rb.erb +5 -3
- data/lib/generators/servus/service/service_generator.rb +1 -1
- data/lib/servus/base.rb +67 -9
- data/lib/servus/config.rb +71 -3
- data/lib/servus/events/bus.rb +29 -0
- data/lib/servus/events/emitter.rb +15 -0
- data/lib/servus/extensions/lazily/call.rb +82 -0
- data/lib/servus/extensions/lazily/errors.rb +37 -0
- data/lib/servus/extensions/lazily/ext.rb +23 -0
- data/lib/servus/extensions/lazily/resolver.rb +32 -0
- data/lib/servus/guard.rb +7 -6
- data/lib/servus/guards/falsey_guard.rb +3 -3
- data/lib/servus/guards/presence_guard.rb +4 -4
- data/lib/servus/guards/state_guard.rb +4 -5
- data/lib/servus/guards/truthy_guard.rb +3 -3
- data/lib/servus/helpers/controller_helpers.rb +40 -0
- data/lib/servus/railtie.rb +7 -1
- data/lib/servus/support/data_object.rb +80 -0
- data/lib/servus/support/errors.rb +16 -0
- data/lib/servus/support/lockdown.rb +94 -0
- data/lib/servus/support/logger.rb +16 -0
- data/lib/servus/support/response.rb +12 -1
- data/lib/servus/support/validator.rb +79 -34
- data/lib/servus/testing/example_builders.rb +74 -0
- data/lib/servus/testing/matchers.rb +99 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +2 -0
- metadata +16 -114
- data/.claude/commands/check-docs.md +0 -1
- data/.claude/commands/consistency-check.md +0 -1
- data/.claude/commands/fine-tooth-comb.md +0 -1
- data/.claude/commands/red-green-refactor.md +0 -5
- data/.claude/settings.json +0 -24
- data/.rspec +0 -3
- data/.rubocop.yml +0 -27
- data/.yardopts +0 -6
- data/CHANGELOG.md +0 -122
- data/CLAUDE.md +0 -10
- data/IDEAS.md +0 -5
- data/LICENSE.txt +0 -21
- data/READme.md +0 -856
- data/Rakefile +0 -45
- data/docs/core/1_overview.md +0 -77
- data/docs/core/2_architecture.md +0 -120
- data/docs/core/3_service_objects.md +0 -121
- data/docs/features/1_schema_validation.md +0 -119
- data/docs/features/2_error_handling.md +0 -121
- data/docs/features/3_async_execution.md +0 -81
- data/docs/features/4_logging.md +0 -64
- data/docs/features/5_event_bus.md +0 -244
- data/docs/features/6_guards.md +0 -356
- data/docs/features/guards_naming_convention.md +0 -540
- data/docs/guides/1_common_patterns.md +0 -90
- data/docs/guides/2_migration_guide.md +0 -175
- data/docs/integration/1_configuration.md +0 -154
- data/docs/integration/2_testing.md +0 -287
- data/docs/integration/3_rails_integration.md +0 -99
- data/docs/yard/Servus/Base.html +0 -1645
- data/docs/yard/Servus/Config.html +0 -582
- data/docs/yard/Servus/Extensions/Async/Call.html +0 -400
- data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +0 -140
- data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +0 -154
- data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +0 -154
- data/docs/yard/Servus/Extensions/Async/Errors.html +0 -128
- data/docs/yard/Servus/Extensions/Async/Ext.html +0 -119
- data/docs/yard/Servus/Extensions/Async/Job.html +0 -310
- data/docs/yard/Servus/Extensions/Async.html +0 -141
- data/docs/yard/Servus/Extensions.html +0 -117
- data/docs/yard/Servus/Generators/ServiceGenerator.html +0 -261
- data/docs/yard/Servus/Generators.html +0 -115
- data/docs/yard/Servus/Helpers/ControllerHelpers.html +0 -457
- data/docs/yard/Servus/Helpers.html +0 -115
- data/docs/yard/Servus/Railtie.html +0 -134
- data/docs/yard/Servus/Support/Errors/AuthenticationError.html +0 -287
- data/docs/yard/Servus/Support/Errors/BadRequestError.html +0 -283
- data/docs/yard/Servus/Support/Errors/ForbiddenError.html +0 -284
- data/docs/yard/Servus/Support/Errors/InternalServerError.html +0 -283
- data/docs/yard/Servus/Support/Errors/NotFoundError.html +0 -284
- data/docs/yard/Servus/Support/Errors/ServiceError.html +0 -489
- data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +0 -290
- data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +0 -200
- data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +0 -288
- data/docs/yard/Servus/Support/Errors/ValidationError.html +0 -200
- data/docs/yard/Servus/Support/Errors.html +0 -140
- data/docs/yard/Servus/Support/Logger.html +0 -856
- data/docs/yard/Servus/Support/Rescuer/BlockContext.html +0 -585
- data/docs/yard/Servus/Support/Rescuer/CallOverride.html +0 -257
- data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +0 -343
- data/docs/yard/Servus/Support/Rescuer.html +0 -267
- data/docs/yard/Servus/Support/Response.html +0 -574
- data/docs/yard/Servus/Support/Validator.html +0 -1150
- data/docs/yard/Servus/Support.html +0 -119
- data/docs/yard/Servus/Testing/ExampleBuilders.html +0 -523
- data/docs/yard/Servus/Testing/ExampleExtractor.html +0 -578
- data/docs/yard/Servus/Testing.html +0 -142
- data/docs/yard/Servus.html +0 -343
- data/docs/yard/_index.html +0 -535
- data/docs/yard/class_list.html +0 -54
- data/docs/yard/css/common.css +0 -1
- data/docs/yard/css/full_list.css +0 -58
- data/docs/yard/css/style.css +0 -503
- data/docs/yard/file.1_common_patterns.html +0 -154
- data/docs/yard/file.1_configuration.html +0 -115
- data/docs/yard/file.1_overview.html +0 -142
- data/docs/yard/file.1_schema_validation.html +0 -188
- data/docs/yard/file.2_architecture.html +0 -157
- data/docs/yard/file.2_error_handling.html +0 -190
- data/docs/yard/file.2_migration_guide.html +0 -242
- data/docs/yard/file.2_testing.html +0 -227
- data/docs/yard/file.3_async_execution.html +0 -145
- data/docs/yard/file.3_rails_integration.html +0 -160
- data/docs/yard/file.3_service_objects.html +0 -191
- data/docs/yard/file.4_logging.html +0 -135
- data/docs/yard/file.ErrorHandling.html +0 -190
- data/docs/yard/file.READme.html +0 -674
- data/docs/yard/file.architecture.html +0 -157
- data/docs/yard/file.async_execution.html +0 -145
- data/docs/yard/file.common_patterns.html +0 -154
- data/docs/yard/file.configuration.html +0 -115
- data/docs/yard/file.error_handling.html +0 -190
- data/docs/yard/file.logging.html +0 -135
- data/docs/yard/file.migration_guide.html +0 -242
- data/docs/yard/file.overview.html +0 -142
- data/docs/yard/file.rails_integration.html +0 -160
- data/docs/yard/file.schema_validation.html +0 -188
- data/docs/yard/file.service_objects.html +0 -191
- data/docs/yard/file.testing.html +0 -227
- data/docs/yard/file_list.html +0 -119
- data/docs/yard/frames.html +0 -22
- data/docs/yard/index.html +0 -674
- data/docs/yard/js/app.js +0 -344
- data/docs/yard/js/full_list.js +0 -242
- data/docs/yard/js/jquery.js +0 -4
- data/docs/yard/method_list.html +0 -542
- 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
|
-
```
|