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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 871333d0568c05b07e86cb1c642d54877cbb33f3a2a1c29267da3ed688389129
4
- data.tar.gz: 103f4c7d0662f9511ca2d155d783285184bacabdeb5a15c0b81d346ccaba3b41
3
+ metadata.gz: f63266b1efb1e782745c67d24957ce1126b1031592667bd78fe80f5ed2c93ac7
4
+ data.tar.gz: 64d263185dc9214707aafc7eaaf740343e5c24311eec49db6bbccff25fa413a9
5
5
  SHA512:
6
- metadata.gz: bd690d583dbe42aacd6b87f84a1dedbec2313c74e409f6d03b2da34807e6512c660c8b5e7d2d87c1baa7a2837531e52b7b72b325fe94b62a926497d0af26dfb1
7
- data.tar.gz: 204699451cdf2f8b2974a8194f62ba83866180cbb3edf221d704356fdc706f53ae3ae859df4bd5e43b009cbb8c01967e052aecdb8582cd3ca5e8676a2b467b3b
6
+ metadata.gz: a6d3ff24ec58b33b926acca83447ede76c1feb54daab1ade3b4a744f66e70a87785b68d6e235934b6b20f6477e7518441486bb6d405f34cd8bfb2334c095122e
7
+ data.tar.gz: 5f562d3cac4a258387fab0936d8fecb41e9e42803556014d91c5802f0b27f6b5cd2a085f76b4d0d486c609f2589c286d5fe13fbd4be34270f4ecc104d2b478a1
@@ -1,4 +1,13 @@
1
1
  {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(bundle exec rspec:*)",
5
+ "Bash(bundle exec rubocop:*)",
6
+ "Bash(find:*)"
7
+ ],
8
+ "deny": [],
9
+ "ask": []
10
+ },
2
11
  "hooks": {
3
12
  "PostToolUse": [
4
13
  {
data/CHANGELOG.md CHANGED
@@ -1,4 +1,42 @@
1
- ## [Unreleased]
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 `render_service_object_error` helpers.
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 set's an instance variable `@result` to the
360
- result of the service object if the result is successful. If the result is not successful, it will pass the result
361
- to error to the `render_service_object_error` helper. This allows for easy error handling in the controller for
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
- render_service_object_error(result.error.api_error)
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
- ### render_service_object_error
469
+ ### render_service_error
381
470
 
382
- `render_service_object_error` renders the error of a service object. It expects a hash with a `message` key and a `code` key from
383
- the api_error method of the service error. This is all setup by default for a JSON API response, thought the method can be
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, render_service_object_error calls the following:
475
+ # Behind the scenes, render_service_error calls the following:
388
476
  #
389
- # error = result.error.api_error
390
- # => { message: "Error message", code: 400 }
477
+ # render json: { error: error.api_error }, status: error.http_status
391
478
  #
392
- # render json: { message: error[:message], code: error[:code] }, status: error[:code]
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
- render_service_object_error(result.error.api_error)
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
+ ```