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/docs/features/4_logging.md
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# @title Features / 4. Logging
|
|
2
|
-
|
|
3
|
-
# Logging
|
|
4
|
-
|
|
5
|
-
Servus automatically logs all service executions with timing information. No instrumentation code needed in services.
|
|
6
|
-
|
|
7
|
-
## What Gets Logged
|
|
8
|
-
|
|
9
|
-
**Service calls** (DEBUG): Service class name and arguments
|
|
10
|
-
```
|
|
11
|
-
[Servus] Users::Create::Service called with {:email=>"user@example.com", :name=>"John"}
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
**Successful completions** (INFO): Service class name and duration
|
|
15
|
-
```
|
|
16
|
-
[Servus] Users::Create::Service completed successfully in 0.0243s
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
**Failures** (WARN): Service class name, error type, message, and duration
|
|
20
|
-
```
|
|
21
|
-
[Servus] Users::Create::Service failed with NotFoundError: User not found (0.0125s)
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
**Exceptions** (ERROR): Service class name, exception type, message, and duration
|
|
25
|
-
```
|
|
26
|
-
[Servus] Users::Create::Service raised ArgumentError: Missing required field (0.0089s)
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
## Log Levels
|
|
30
|
-
|
|
31
|
-
Servus uses Rails.logger and respects application log level configuration:
|
|
32
|
-
|
|
33
|
-
- **DEBUG**: Shows arguments (use in development, hide in production to avoid logging sensitive data)
|
|
34
|
-
- **INFO**: Shows completions (normal operations)
|
|
35
|
-
- **WARN**: Shows business failures
|
|
36
|
-
- **ERROR**: Shows system exceptions
|
|
37
|
-
|
|
38
|
-
Set production log level to INFO to hide argument logging:
|
|
39
|
-
|
|
40
|
-
```ruby
|
|
41
|
-
# config/environments/production.rb
|
|
42
|
-
config.log_level = :info
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## Sensitive Data
|
|
46
|
-
|
|
47
|
-
Arguments are logged at DEBUG level. In production, either:
|
|
48
|
-
1. Set log level to INFO (recommended)
|
|
49
|
-
2. Use Rails parameter filtering: `config.filter_parameters += [:password, :ssn, :credit_card]`
|
|
50
|
-
3. Pass IDs instead of full objects: `Service.call(user_id: 1)` not `Service.call(user: user_object)`
|
|
51
|
-
|
|
52
|
-
## Integration with Logging Tools
|
|
53
|
-
|
|
54
|
-
The `[Servus]` prefix makes service logs easy to grep and filter:
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
# Find all service calls
|
|
58
|
-
grep "\[Servus\]" production.log
|
|
59
|
-
|
|
60
|
-
# Find slow services
|
|
61
|
-
grep "completed" production.log | grep "Servus" | awk '{print $NF}' | sort -n
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Servus logs work with structured logging tools (Lograge, Datadog, Splunk) without modification.
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
# @title Features / 5. Event Bus
|
|
2
|
-
|
|
3
|
-
# Event Bus
|
|
4
|
-
|
|
5
|
-
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.
|
|
6
|
-
|
|
7
|
-
## Overview
|
|
8
|
-
|
|
9
|
-
The Event Bus provides:
|
|
10
|
-
- **Emitters**: Services declare events they emit on success/failure
|
|
11
|
-
- **EventHandlers**: Subscribe to events and invoke services in response
|
|
12
|
-
- **Event Bus**: Routes events to registered handlers via ActiveSupport::Notifications
|
|
13
|
-
- **Payload Validation**: Optional JSON Schema validation for event payloads
|
|
14
|
-
|
|
15
|
-
## Service Event Emission
|
|
16
|
-
|
|
17
|
-
Services can emit events when they succeed or fail using the `emits` DSL:
|
|
18
|
-
|
|
19
|
-
```ruby
|
|
20
|
-
class CreateUser::Service < Servus::Base
|
|
21
|
-
emits :user_created, on: :success
|
|
22
|
-
emits :user_creation_failed, on: :failure
|
|
23
|
-
|
|
24
|
-
def initialize(email:, name:)
|
|
25
|
-
@email = email
|
|
26
|
-
@name = name
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def call
|
|
30
|
-
user = User.create!(email: @email, name: @name)
|
|
31
|
-
success(user: user)
|
|
32
|
-
rescue ActiveRecord::RecordInvalid => e
|
|
33
|
-
failure(e.message)
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
### Custom Payloads
|
|
39
|
-
|
|
40
|
-
By default, success events receive `result.data` and failure events receive `result.error`. Customize with a block or method:
|
|
41
|
-
|
|
42
|
-
```ruby
|
|
43
|
-
class CreateUser::Service < Servus::Base
|
|
44
|
-
# Block-based payload
|
|
45
|
-
emits :user_created, on: :success do |result|
|
|
46
|
-
{ user_id: result.data[:user].id, email: result.data[:user].email }
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Method-based payload
|
|
50
|
-
emits :user_stats_updated, on: :success, with: :stats_payload
|
|
51
|
-
|
|
52
|
-
private
|
|
53
|
-
|
|
54
|
-
def stats_payload(result)
|
|
55
|
-
{ user_count: User.count, latest_user_id: result.data[:user].id }
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
### Trigger Types
|
|
61
|
-
|
|
62
|
-
- `:success` - Fires when service returns `success(...)`
|
|
63
|
-
- `:failure` - Fires when service returns `failure(...)`
|
|
64
|
-
- `:error!` - Fires when service calls `error!(...)` (before exception is raised)
|
|
65
|
-
|
|
66
|
-
## Event Handlers
|
|
67
|
-
|
|
68
|
-
EventHandlers live in `app/events/` and subscribe to events using a declarative DSL:
|
|
69
|
-
|
|
70
|
-
```ruby
|
|
71
|
-
# app/events/user_created_handler.rb
|
|
72
|
-
class UserCreatedHandler < Servus::EventHandler
|
|
73
|
-
handles :user_created
|
|
74
|
-
|
|
75
|
-
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
76
|
-
{ user_id: payload[:user_id], email: payload[:email] }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
invoke TrackAnalytics::Service, async: true do |payload|
|
|
80
|
-
{ event: 'user_created', user_id: payload[:user_id] }
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
### Generator
|
|
86
|
-
|
|
87
|
-
Generate handlers with the Rails generator:
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
rails g servus:event_handler user_created
|
|
91
|
-
# Creates:
|
|
92
|
-
# app/events/user_created_handler.rb
|
|
93
|
-
# spec/events/user_created_handler_spec.rb
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
### Invocation Options
|
|
97
|
-
|
|
98
|
-
```ruby
|
|
99
|
-
class UserCreatedHandler < Servus::EventHandler
|
|
100
|
-
handles :user_created
|
|
101
|
-
|
|
102
|
-
# Synchronous invocation (default)
|
|
103
|
-
invoke NotifyAdmin::Service do |payload|
|
|
104
|
-
{ message: "New user: #{payload[:email]}" }
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# Async via ActiveJob
|
|
108
|
-
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
109
|
-
{ user_id: payload[:user_id] }
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Async with specific queue
|
|
113
|
-
invoke SendWelcomeEmail::Service, async: true, queue: :mailers do |payload|
|
|
114
|
-
{ user_id: payload[:user_id] }
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# Conditional invocation
|
|
118
|
-
invoke GrantPremiumRewards::Service, if: ->(p) { p[:premium] } do |payload|
|
|
119
|
-
{ user_id: payload[:user_id] }
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
invoke SkipForPremium::Service, unless: ->(p) { p[:premium] } do |payload|
|
|
123
|
-
{ user_id: payload[:user_id] }
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
## Emitting Events Directly
|
|
129
|
-
|
|
130
|
-
EventHandlers provide an `emit` class method for emitting events from controllers, jobs, or other code without a service:
|
|
131
|
-
|
|
132
|
-
```ruby
|
|
133
|
-
class UsersController < ApplicationController
|
|
134
|
-
def create
|
|
135
|
-
user = User.create!(user_params)
|
|
136
|
-
UserCreatedHandler.emit({ user_id: user.id, email: user.email })
|
|
137
|
-
redirect_to user
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
This is useful when the event source isn't a Servus service.
|
|
143
|
-
|
|
144
|
-
## Payload Schema Validation
|
|
145
|
-
|
|
146
|
-
Define JSON schemas to validate event payloads:
|
|
147
|
-
|
|
148
|
-
```ruby
|
|
149
|
-
class UserCreatedHandler < Servus::EventHandler
|
|
150
|
-
handles :user_created
|
|
151
|
-
|
|
152
|
-
schema payload: {
|
|
153
|
-
type: 'object',
|
|
154
|
-
required: ['user_id', 'email'],
|
|
155
|
-
properties: {
|
|
156
|
-
user_id: { type: 'integer' },
|
|
157
|
-
email: { type: 'string', format: 'email' }
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
162
|
-
{ user_id: payload[:user_id], email: payload[:email] }
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
When `emit` is called, the payload is validated against the schema before the event is dispatched.
|
|
168
|
-
|
|
169
|
-
## Handler Validation
|
|
170
|
-
|
|
171
|
-
Enable strict validation to catch handlers subscribing to non-existent events:
|
|
172
|
-
|
|
173
|
-
```ruby
|
|
174
|
-
# config/initializers/servus.rb
|
|
175
|
-
Servus.configure do |config|
|
|
176
|
-
config.strict_event_validation = true # Default: true
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# Then manually validate (typically in a rake task or CI)
|
|
180
|
-
Servus::EventHandler.validate_all_handlers!
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
This helps catch typos and orphaned handlers during development and CI.
|
|
184
|
-
|
|
185
|
-
## Best Practices
|
|
186
|
-
|
|
187
|
-
### Single Event Per Service
|
|
188
|
-
|
|
189
|
-
Services should emit one event per trigger representing their core concern:
|
|
190
|
-
|
|
191
|
-
```ruby
|
|
192
|
-
# Good - one event, handler coordinates reactions
|
|
193
|
-
class CreateUser::Service < Servus::Base
|
|
194
|
-
emits :user_created, on: :success
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
class UserCreatedHandler < Servus::EventHandler
|
|
198
|
-
handles :user_created
|
|
199
|
-
|
|
200
|
-
invoke SendWelcomeEmail::Service, async: true
|
|
201
|
-
invoke TrackAnalytics::Service, async: true
|
|
202
|
-
invoke NotifySlack::Service, async: true
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# Avoid - service doing too much coordination
|
|
206
|
-
class CreateUser::Service < Servus::Base
|
|
207
|
-
emits :send_welcome_email, on: :success
|
|
208
|
-
emits :track_user_analytics, on: :success
|
|
209
|
-
emits :notify_slack, on: :success
|
|
210
|
-
end
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
### Naming Conventions
|
|
214
|
-
|
|
215
|
-
- Events: Past tense describing what happened (`user_created`, `payment_processed`)
|
|
216
|
-
- Handlers: Event name + "Handler" suffix (`UserCreatedHandler`)
|
|
217
|
-
|
|
218
|
-
### Handler Location
|
|
219
|
-
|
|
220
|
-
Handlers live in `app/events/` and are auto-loaded by the Railtie:
|
|
221
|
-
|
|
222
|
-
```
|
|
223
|
-
app/events/
|
|
224
|
-
├── user_created_handler.rb
|
|
225
|
-
├── payment_processed_handler.rb
|
|
226
|
-
└── order_completed_handler.rb
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
## Instrumentation
|
|
230
|
-
|
|
231
|
-
Events are instrumented via ActiveSupport::Notifications and appear in Rails logs:
|
|
232
|
-
|
|
233
|
-
```
|
|
234
|
-
servus.events.user_created (1.2ms) {:user_id=>123, :email=>"user@example.com"}
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Subscribe to events programmatically:
|
|
238
|
-
|
|
239
|
-
```ruby
|
|
240
|
-
ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, *args|
|
|
241
|
-
event_name = name.sub('servus.events.', '')
|
|
242
|
-
Rails.logger.info "Event emitted: #{event_name}"
|
|
243
|
-
end
|
|
244
|
-
```
|
data/docs/features/6_guards.md
DELETED
|
@@ -1,356 +0,0 @@
|
|
|
1
|
-
# @title Features / 6. Guards
|
|
2
|
-
|
|
3
|
-
# Guards
|
|
4
|
-
|
|
5
|
-
Guards are reusable validation rules that halt service execution when conditions aren't met. They provide a declarative way to enforce preconditions with rich, API-friendly error responses.
|
|
6
|
-
|
|
7
|
-
## Why Guards?
|
|
8
|
-
|
|
9
|
-
Instead of scattering validation logic throughout services:
|
|
10
|
-
|
|
11
|
-
```ruby
|
|
12
|
-
# Without guards - repetitive and verbose
|
|
13
|
-
def call
|
|
14
|
-
return failure("User required", type: ValidationError) unless user
|
|
15
|
-
return failure("User must be active", type: ValidationError) unless user.active?
|
|
16
|
-
# ... business logic ...
|
|
17
|
-
end
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Use guards for clean, declarative validation:
|
|
21
|
-
|
|
22
|
-
```ruby
|
|
23
|
-
# With guards - clear and reusable
|
|
24
|
-
def call
|
|
25
|
-
enforce_presence!(user: user)
|
|
26
|
-
enforce_truthy!(on: user, check: :active)
|
|
27
|
-
# ... business logic ...
|
|
28
|
-
end
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
## Built-in Guards
|
|
32
|
-
|
|
33
|
-
Servus includes four guards by default:
|
|
34
|
-
|
|
35
|
-
### PresenceGuard
|
|
36
|
-
|
|
37
|
-
Validates that all values are present (not nil or empty):
|
|
38
|
-
|
|
39
|
-
```ruby
|
|
40
|
-
# Single value
|
|
41
|
-
enforce_presence!(user: user)
|
|
42
|
-
|
|
43
|
-
# Multiple values - all must be present
|
|
44
|
-
enforce_presence!(user: user, account: account, device: device)
|
|
45
|
-
|
|
46
|
-
# Works with strings, arrays, hashes
|
|
47
|
-
enforce_presence!(email: email) # fails if nil or ""
|
|
48
|
-
enforce_presence!(items: cart.items) # fails if nil or []
|
|
49
|
-
enforce_presence!(data: response.body) # fails if nil or {}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Error: `"user must be present (got nil)"` or `"email must be present (got \"\")"`
|
|
53
|
-
|
|
54
|
-
### TruthyGuard
|
|
55
|
-
|
|
56
|
-
Validates that attribute(s) on an object are truthy:
|
|
57
|
-
|
|
58
|
-
```ruby
|
|
59
|
-
# Single attribute
|
|
60
|
-
enforce_truthy!(on: user, check: :active)
|
|
61
|
-
|
|
62
|
-
# Multiple attributes - all must be truthy
|
|
63
|
-
enforce_truthy!(on: user, check: [:active, :verified, :confirmed])
|
|
64
|
-
|
|
65
|
-
# Conditional check
|
|
66
|
-
if check_truthy?(on: subscription, check: :valid?)
|
|
67
|
-
process_subscription
|
|
68
|
-
end
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Error: `"User.active must be truthy (got false)"`
|
|
72
|
-
|
|
73
|
-
### FalseyGuard
|
|
74
|
-
|
|
75
|
-
Validates that attribute(s) on an object are falsey:
|
|
76
|
-
|
|
77
|
-
```ruby
|
|
78
|
-
# Single attribute - user must not be banned
|
|
79
|
-
enforce_falsey!(on: user, check: :banned)
|
|
80
|
-
|
|
81
|
-
# Multiple attributes - all must be falsey
|
|
82
|
-
enforce_falsey!(on: post, check: [:deleted, :hidden, :flagged])
|
|
83
|
-
|
|
84
|
-
# Conditional check
|
|
85
|
-
if check_falsey?(on: user, check: :suspended)
|
|
86
|
-
allow_action
|
|
87
|
-
end
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
Error: `"User.banned must be falsey (got true)"`
|
|
91
|
-
|
|
92
|
-
### StateGuard
|
|
93
|
-
|
|
94
|
-
Validates that an attribute matches an expected value or one of several allowed values:
|
|
95
|
-
|
|
96
|
-
```ruby
|
|
97
|
-
# Single expected value
|
|
98
|
-
enforce_state!(on: order, check: :status, is: :pending)
|
|
99
|
-
|
|
100
|
-
# Multiple allowed values - any match passes
|
|
101
|
-
enforce_state!(on: account, check: :status, is: [:active, :trial])
|
|
102
|
-
|
|
103
|
-
# Conditional check
|
|
104
|
-
if check_state?(on: order, check: :status, is: :shipped)
|
|
105
|
-
send_tracking_email
|
|
106
|
-
end
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
Errors:
|
|
110
|
-
- Single value: `"Order.status must be pending (got shipped)"`
|
|
111
|
-
- Multiple values: `"Account.status must be one of active, trial (got suspended)"`
|
|
112
|
-
|
|
113
|
-
## Guard Methods
|
|
114
|
-
|
|
115
|
-
Each guard defines two methods on `Servus::Guards`:
|
|
116
|
-
|
|
117
|
-
- **Bang method (`!`)** - Throws on failure, halts execution
|
|
118
|
-
- **Predicate method (`?`)** - Returns boolean, continues execution
|
|
119
|
-
|
|
120
|
-
```ruby
|
|
121
|
-
# Bang method - use for preconditions that must pass
|
|
122
|
-
enforce_presence!(user: user) # throws :guard_failure if nil
|
|
123
|
-
|
|
124
|
-
# Predicate method - use for conditional logic
|
|
125
|
-
if check_truthy?(on: account, check: :premium)
|
|
126
|
-
apply_premium_discount
|
|
127
|
-
else
|
|
128
|
-
apply_standard_rate
|
|
129
|
-
end
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
## Creating Custom Guards
|
|
133
|
-
|
|
134
|
-
Define guards by inheriting from `Servus::Guard`:
|
|
135
|
-
|
|
136
|
-
```ruby
|
|
137
|
-
# app/guards/sufficient_balance_guard.rb
|
|
138
|
-
class SufficientBalanceGuard < Servus::Guard
|
|
139
|
-
http_status 422
|
|
140
|
-
error_code 'insufficient_balance'
|
|
141
|
-
|
|
142
|
-
message 'Insufficient balance: need %<required>s, have %<available>s' do
|
|
143
|
-
message_data
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def test(account:, amount:)
|
|
147
|
-
account.balance >= amount
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
private
|
|
151
|
-
|
|
152
|
-
def message_data
|
|
153
|
-
{
|
|
154
|
-
required: kwargs[:amount],
|
|
155
|
-
available: kwargs[:account].balance
|
|
156
|
-
}
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
This automatically defines `enforce_sufficient_balance!` and `check_sufficient_balance?` methods.
|
|
162
|
-
|
|
163
|
-
### Guard DSL
|
|
164
|
-
|
|
165
|
-
**`http_status`** - HTTP status code for API responses (default: 422)
|
|
166
|
-
|
|
167
|
-
```ruby
|
|
168
|
-
http_status 422 # Unprocessable Entity
|
|
169
|
-
http_status 403 # Forbidden
|
|
170
|
-
http_status 400 # Bad Request
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
**`error_code`** - Machine-readable error code for API clients
|
|
174
|
-
|
|
175
|
-
```ruby
|
|
176
|
-
error_code 'insufficient_balance'
|
|
177
|
-
error_code 'daily_limit_exceeded'
|
|
178
|
-
error_code 'account_locked'
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
**`message`** - Human-readable error message with optional interpolation
|
|
182
|
-
|
|
183
|
-
```ruby
|
|
184
|
-
# Static message
|
|
185
|
-
message 'Amount must be positive'
|
|
186
|
-
|
|
187
|
-
# With interpolation (uses Ruby's % formatting)
|
|
188
|
-
message 'Balance: %<current>s, Required: %<required>s' do
|
|
189
|
-
{ current: account.balance, required: amount }
|
|
190
|
-
end
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
The message block has access to all kwargs passed to the guard via `kwargs`.
|
|
194
|
-
|
|
195
|
-
**`test`** - The validation logic (must return boolean)
|
|
196
|
-
|
|
197
|
-
```ruby
|
|
198
|
-
def test(account:, amount:)
|
|
199
|
-
account.balance >= amount
|
|
200
|
-
end
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
## Message Templates
|
|
204
|
-
|
|
205
|
-
Guards support multiple message template formats:
|
|
206
|
-
|
|
207
|
-
### String with Interpolation
|
|
208
|
-
|
|
209
|
-
```ruby
|
|
210
|
-
message 'Insufficient balance: need %<required>s, have %<available>s' do
|
|
211
|
-
{ required: amount, available: account.balance }
|
|
212
|
-
end
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
### I18n Symbol
|
|
216
|
-
|
|
217
|
-
```ruby
|
|
218
|
-
message :insufficient_balance
|
|
219
|
-
# Looks up: I18n.t('guards.insufficient_balance')
|
|
220
|
-
# Falls back to: "Insufficient balance" (humanized)
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
### Inline Translations
|
|
224
|
-
|
|
225
|
-
```ruby
|
|
226
|
-
message(
|
|
227
|
-
en: 'Insufficient balance',
|
|
228
|
-
es: 'Saldo insuficiente',
|
|
229
|
-
fr: 'Solde insuffisant'
|
|
230
|
-
)
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
### Dynamic Proc
|
|
234
|
-
|
|
235
|
-
```ruby
|
|
236
|
-
message -> { "Limit exceeded for #{limit_type} transfers" }
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
## Error Handling
|
|
240
|
-
|
|
241
|
-
When a bang guard fails, it throws `:guard_failure` with a `GuardError`. Services automatically catch this and return a failure response:
|
|
242
|
-
|
|
243
|
-
```ruby
|
|
244
|
-
class TransferService < Servus::Base
|
|
245
|
-
def call
|
|
246
|
-
enforce_state!(on: from_account, check: :status, is: :active)
|
|
247
|
-
# If guard fails, execution stops here
|
|
248
|
-
# Service returns: Response(success: false, error: GuardError)
|
|
249
|
-
|
|
250
|
-
transfer_funds
|
|
251
|
-
success(transfer: transfer)
|
|
252
|
-
end
|
|
253
|
-
end
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
The `GuardError` includes all metadata:
|
|
257
|
-
|
|
258
|
-
```ruby
|
|
259
|
-
error = guard.error
|
|
260
|
-
error.message # "Account.status must be active (got suspended)"
|
|
261
|
-
error.code # "invalid_state"
|
|
262
|
-
error.http_status # 422
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
## Naming Convention
|
|
266
|
-
|
|
267
|
-
Guard class names are converted to method names by stripping the `Guard` suffix and converting to snake_case:
|
|
268
|
-
|
|
269
|
-
| Class Name | Bang Method | Predicate Method |
|
|
270
|
-
|------------|-------------|------------------|
|
|
271
|
-
| `SufficientBalanceGuard` | `enforce_sufficient_balance!` | `check_sufficient_balance?` |
|
|
272
|
-
| `ValidAmountGuard` | `enforce_valid_amount!` | `check_valid_amount?` |
|
|
273
|
-
| `AuthorizedGuard` | `enforce_authorized!` | `check_authorized?` |
|
|
274
|
-
|
|
275
|
-
The built-in guards follow this pattern: `TruthyGuard` -> `enforce_truthy!` / `check_truthy?`.
|
|
276
|
-
|
|
277
|
-
## Rails Auto-Loading
|
|
278
|
-
|
|
279
|
-
In Rails, guards in `app/guards/` are automatically loaded. Files must follow the `*_guard.rb` naming convention:
|
|
280
|
-
|
|
281
|
-
```
|
|
282
|
-
app/guards/
|
|
283
|
-
├── sufficient_balance_guard.rb
|
|
284
|
-
├── valid_amount_guard.rb
|
|
285
|
-
└── authorized_guard.rb
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
## Configuration
|
|
289
|
-
|
|
290
|
-
Disable built-in guards if you want to define your own (you can have both):
|
|
291
|
-
|
|
292
|
-
```ruby
|
|
293
|
-
Servus.configure do |config|
|
|
294
|
-
config.include_default_guards = false # Default: true
|
|
295
|
-
config.guards_dir = 'app/guards' # Default: 'app/guards'
|
|
296
|
-
end
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
## Testing Guards
|
|
300
|
-
|
|
301
|
-
Test guards in isolation:
|
|
302
|
-
|
|
303
|
-
```ruby
|
|
304
|
-
RSpec.describe Servus::Guards::TruthyGuard do
|
|
305
|
-
let(:user_class) do
|
|
306
|
-
Struct.new(:active, :verified, keyword_init: true) do
|
|
307
|
-
def self.name
|
|
308
|
-
'User'
|
|
309
|
-
end
|
|
310
|
-
end
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
describe '#test' do
|
|
314
|
-
it 'passes when attribute is truthy' do
|
|
315
|
-
user = user_class.new(active: true)
|
|
316
|
-
guard = described_class.new(on: user, check: :active)
|
|
317
|
-
expect(guard.test(on: user, check: :active)).to be true
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
it 'fails when attribute is falsey' do
|
|
321
|
-
user = user_class.new(active: false)
|
|
322
|
-
guard = described_class.new(on: user, check: :active)
|
|
323
|
-
expect(guard.test(on: user, check: :active)).to be false
|
|
324
|
-
end
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
describe '#error' do
|
|
328
|
-
it 'returns GuardError with correct metadata' do
|
|
329
|
-
user = user_class.new(active: false)
|
|
330
|
-
guard = described_class.new(on: user, check: :active)
|
|
331
|
-
error = guard.error
|
|
332
|
-
|
|
333
|
-
expect(error.code).to eq('must_be_truthy')
|
|
334
|
-
expect(error.message).to include('User', 'active', 'false')
|
|
335
|
-
expect(error.http_status).to eq(422)
|
|
336
|
-
end
|
|
337
|
-
end
|
|
338
|
-
end
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
Test guards in service integration:
|
|
342
|
-
|
|
343
|
-
```ruby
|
|
344
|
-
RSpec.describe TransferService do
|
|
345
|
-
it 'fails when account is not active' do
|
|
346
|
-
result = described_class.call(
|
|
347
|
-
from_account: suspended_account,
|
|
348
|
-
to_account: recipient,
|
|
349
|
-
amount: 100
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
expect(result).to be_failure
|
|
353
|
-
expect(result.error.code).to eq('invalid_state')
|
|
354
|
-
end
|
|
355
|
-
end
|
|
356
|
-
```
|