servus 0.1.6 → 0.2.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/.claude/settings.json +9 -0
- data/CHANGELOG.md +39 -1
- data/READme.md +120 -15
- data/docs/features/6_guards.md +356 -0
- data/docs/features/guards_naming_convention.md +540 -0
- data/docs/integration/1_configuration.md +52 -2
- data/lib/generators/servus/guard/guard_generator.rb +75 -0
- data/lib/generators/servus/guard/templates/guard.rb.erb +69 -0
- data/lib/generators/servus/guard/templates/guard_spec.rb.erb +65 -0
- data/lib/servus/base.rb +9 -1
- data/lib/servus/config.rb +17 -2
- data/lib/servus/guard.rb +289 -0
- data/lib/servus/guards/falsey_guard.rb +59 -0
- data/lib/servus/guards/presence_guard.rb +80 -0
- data/lib/servus/guards/state_guard.rb +62 -0
- data/lib/servus/guards/truthy_guard.rb +61 -0
- data/lib/servus/guards.rb +48 -0
- data/lib/servus/helpers/controller_helpers.rb +20 -48
- data/lib/servus/railtie.rb +11 -3
- data/lib/servus/support/errors.rb +69 -140
- data/lib/servus/support/message_resolver.rb +166 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +5 -0
- metadata +13 -8
- data/builds/servus-0.0.1.gem +0 -0
- data/builds/servus-0.1.1.gem +0 -0
- data/builds/servus-0.1.2.gem +0 -0
- data/builds/servus-0.1.3.gem +0 -0
- data/builds/servus-0.1.4.gem +0 -0
- data/builds/servus-0.1.5.gem +0 -0
- data/docs/current_focus.md +0 -569
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f63266b1efb1e782745c67d24957ce1126b1031592667bd78fe80f5ed2c93ac7
|
|
4
|
+
data.tar.gz: 64d263185dc9214707aafc7eaaf740343e5c24311eec49db6bbccff25fa413a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6d3ff24ec58b33b926acca83447ede76c1feb54daab1ade3b4a744f66e70a87785b68d6e235934b6b20f6477e7518441486bb6d405f34cd8bfb2334c095122e
|
|
7
|
+
data.tar.gz: 5f562d3cac4a258387fab0936d8fecb41e9e42803556014d91c5802f0b27f6b5cd2a085f76b4d0d486c609f2589c286d5fe13fbd4be34270f4ecc104d2b478a1
|
data/.claude/settings.json
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [0.2.0] - 2025-12-16
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Guards System**: Reusable validation rules with rich error responses
|
|
6
|
+
- `Servus::Guard` base class for creating custom guards
|
|
7
|
+
- `Servus::Guards` module included in services with `enforce_*!` and `check_*?` methods
|
|
8
|
+
- Built-in guards:
|
|
9
|
+
- `PresenceGuard` - validates values are present (not nil or empty)
|
|
10
|
+
- `TruthyGuard` - validates object attributes are truthy
|
|
11
|
+
- `FalseyGuard` - validates object attributes are falsey
|
|
12
|
+
- `StateGuard` - validates object attributes match expected value(s)
|
|
13
|
+
- Guards auto-define methods when classes inherit from `Servus::Guard`
|
|
14
|
+
- Guard DSL: `http_status`, `error_code`, `message` with interpolation support
|
|
15
|
+
- Multiple message template formats: String, I18n Symbol, inline Hash, Proc
|
|
16
|
+
- Rails auto-loading from `app/guards/*_guard.rb`
|
|
17
|
+
- Configuration options: `guards_dir`, `include_default_guards`
|
|
18
|
+
|
|
19
|
+
- **GuardError**: New error class for guard validation failures
|
|
20
|
+
- Custom `code` and `http_status` per guard
|
|
21
|
+
- Services catch `:guard_failure` and wrap in failure response automatically
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **Error API Refactored**: Cleaner separation of HTTP status and error body
|
|
26
|
+
- All errors now have `http_status` method returning Rails status symbol
|
|
27
|
+
- `api_error` returns `{ code:, message: }` for response body only
|
|
28
|
+
- Follows community conventions (Stripe, JSON:API) where HTTP status is in header
|
|
29
|
+
|
|
30
|
+
- **Controller Helpers Refactored**:
|
|
31
|
+
- Renamed `render_service_object_error` to `render_service_error`
|
|
32
|
+
- Now takes error object directly instead of `api_error` hash
|
|
33
|
+
- Response format: `{ error: { code:, message: } }` with status from `error.http_status`
|
|
34
|
+
|
|
35
|
+
### Breaking Changes
|
|
36
|
+
|
|
37
|
+
- `render_service_object_error` renamed to `render_service_error`
|
|
38
|
+
- `render_service_error` now accepts error object, not hash: `render_service_error(result.error)` instead of `render_service_error(result.error.api_error)`
|
|
39
|
+
- Error response JSON structure changed from `{ code:, message: }` to `{ error: { code:, message: } }`
|
|
2
40
|
|
|
3
41
|
## [0.1.6] - 2025-12-06
|
|
4
42
|
|
data/READme.md
CHANGED
|
@@ -350,16 +350,105 @@ end
|
|
|
350
350
|
|
|
351
351
|
The block receives the exception and has access to `success` and `failure` methods for creating the response.
|
|
352
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
|
+
|
|
353
443
|
## Controller Helpers
|
|
354
444
|
|
|
355
|
-
Service objects can be called from controllers using the `run_service` and `
|
|
445
|
+
Service objects can be called from controllers using the `run_service` and `render_service_error` helpers.
|
|
356
446
|
|
|
357
447
|
### run_service
|
|
358
448
|
|
|
359
|
-
`run_service` calls the service object with the provided parameters and
|
|
360
|
-
result of the service object
|
|
361
|
-
|
|
362
|
-
repetetive usecases.
|
|
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.
|
|
363
452
|
|
|
364
453
|
```ruby
|
|
365
454
|
class SomeController < AppController
|
|
@@ -367,7 +456,7 @@ class SomeController < AppController
|
|
|
367
456
|
def controller_action
|
|
368
457
|
result = Services::SomeServiceObject::Service.call(my_params)
|
|
369
458
|
return if result.success?
|
|
370
|
-
|
|
459
|
+
render_service_error(result.error)
|
|
371
460
|
end
|
|
372
461
|
|
|
373
462
|
# After
|
|
@@ -377,26 +466,42 @@ class SomeController < AppController
|
|
|
377
466
|
end
|
|
378
467
|
```
|
|
379
468
|
|
|
380
|
-
###
|
|
469
|
+
### render_service_error
|
|
381
470
|
|
|
382
|
-
`
|
|
383
|
-
|
|
384
|
-
overridden if needed to handle different usecases.
|
|
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.
|
|
385
473
|
|
|
386
474
|
```ruby
|
|
387
|
-
# Behind the scenes,
|
|
475
|
+
# Behind the scenes, render_service_error calls the following:
|
|
388
476
|
#
|
|
389
|
-
# error
|
|
390
|
-
# => { message: "Error message", code: 400 }
|
|
477
|
+
# render json: { error: error.api_error }, status: error.http_status
|
|
391
478
|
#
|
|
392
|
-
#
|
|
479
|
+
# Which produces a response like:
|
|
480
|
+
# { "error": { "code": "not_found", "message": "User not found" } }
|
|
481
|
+
# with HTTP status 404
|
|
393
482
|
|
|
394
483
|
class SomeController < AppController
|
|
395
484
|
def controller_action
|
|
396
485
|
result = Services::SomeServiceObject::Service.call(my_params)
|
|
397
486
|
return if result.success?
|
|
398
487
|
|
|
399
|
-
|
|
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
|
|
400
505
|
end
|
|
401
506
|
end
|
|
402
507
|
```
|
|
@@ -0,0 +1,356 @@
|
|
|
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
|
+
```
|