servus 0.1.5 → 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 +45 -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/events/emitter.rb +3 -3
- 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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<%- unless options[:no_docs] -%>
|
|
4
|
+
# Guard that validates <%= file_name.humanize.downcase %> conditions.
|
|
5
|
+
#
|
|
6
|
+
# Guards are reusable validation rules that halt service execution when
|
|
7
|
+
# conditions aren't met. They provide declarative precondition checking
|
|
8
|
+
# with rich error responses.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage in a service
|
|
11
|
+
# class MyService < Servus::Base
|
|
12
|
+
# def call
|
|
13
|
+
# <%= enforce_method_name %>(arg: value)
|
|
14
|
+
# # ... business logic ...
|
|
15
|
+
# success(result)
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Conditional check (returns boolean, doesn't halt)
|
|
20
|
+
# if <%= check_method_name %>(arg: value)
|
|
21
|
+
# # condition is met
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @see Servus::Guard
|
|
25
|
+
# @see Servus::Guards
|
|
26
|
+
<%- end -%>
|
|
27
|
+
class <%= guard_class_name %> < Servus::Guard
|
|
28
|
+
http_status 422
|
|
29
|
+
error_code '<%= file_name %>'
|
|
30
|
+
|
|
31
|
+
message '%<class_name>s <%= file_name.humanize.downcase %> validation failed' do
|
|
32
|
+
message_data
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Tests whether the <%= file_name.humanize.downcase %> condition is met.
|
|
36
|
+
#
|
|
37
|
+
# @param kwargs [Hash] the arguments to validate
|
|
38
|
+
# @return [Boolean] true if validation passes
|
|
39
|
+
def test(**kwargs)
|
|
40
|
+
# TODO: Implement validation logic
|
|
41
|
+
# Return true if the condition is met, false otherwise
|
|
42
|
+
#
|
|
43
|
+
# Example:
|
|
44
|
+
# def test(account:, amount:)
|
|
45
|
+
# account.balance >= amount
|
|
46
|
+
# end
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Builds the interpolation data for the error message.
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash] message interpolation data
|
|
55
|
+
def message_data
|
|
56
|
+
# TODO: Return hash of values for message interpolation
|
|
57
|
+
# Access guard arguments via kwargs[:argument_name]
|
|
58
|
+
#
|
|
59
|
+
# Example:
|
|
60
|
+
# {
|
|
61
|
+
# class_name: kwargs[:account].class.name,
|
|
62
|
+
# balance: kwargs[:account].balance,
|
|
63
|
+
# required: kwargs[:amount]
|
|
64
|
+
# }
|
|
65
|
+
{
|
|
66
|
+
class_name: 'Object'
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe <%= guard_class_name %> do
|
|
6
|
+
<%- unless options[:no_docs] -%>
|
|
7
|
+
# TODO: Define a test class or use a real model
|
|
8
|
+
# let(:test_class) do
|
|
9
|
+
# Struct.new(:attribute, keyword_init: true) do
|
|
10
|
+
# def self.name
|
|
11
|
+
# 'TestModel'
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
|
|
16
|
+
<%- end -%>
|
|
17
|
+
describe '#test' do
|
|
18
|
+
<%- unless options[:no_docs] -%>
|
|
19
|
+
# TODO: Add test cases for your guard logic
|
|
20
|
+
#
|
|
21
|
+
# Example:
|
|
22
|
+
# context 'when condition is met' do
|
|
23
|
+
# it 'returns true' do
|
|
24
|
+
# object = test_class.new(attribute: valid_value)
|
|
25
|
+
# guard = described_class.new(object: object)
|
|
26
|
+
# expect(guard.test(object: object)).to be true
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# context 'when condition is not met' do
|
|
31
|
+
# it 'returns false' do
|
|
32
|
+
# object = test_class.new(attribute: invalid_value)
|
|
33
|
+
# guard = described_class.new(object: object)
|
|
34
|
+
# expect(guard.test(object: object)).to be false
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
|
|
38
|
+
<%- end -%>
|
|
39
|
+
it 'returns true when validation passes' do
|
|
40
|
+
guard = described_class.new
|
|
41
|
+
expect(guard.test).to be true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#error' do
|
|
46
|
+
it 'returns a GuardError with correct metadata' do
|
|
47
|
+
guard = described_class.new
|
|
48
|
+
error = guard.error
|
|
49
|
+
|
|
50
|
+
expect(error).to be_a(Servus::Support::Errors::GuardError)
|
|
51
|
+
expect(error.code).to eq('<%= file_name %>')
|
|
52
|
+
expect(error.http_status).to eq(422)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe 'method registration' do
|
|
57
|
+
it 'defines <%= enforce_method_name %> on Servus::Guards' do
|
|
58
|
+
expect(Servus::Guards.method_defined?(:<%= enforce_method_name %>)).to be true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'defines <%= check_method_name %> on Servus::Guards' do
|
|
62
|
+
expect(Servus::Guards.method_defined?(:<%= check_method_name %>)).to be true
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/servus/base.rb
CHANGED
|
@@ -49,6 +49,7 @@ module Servus
|
|
|
49
49
|
include Servus::Support::Errors
|
|
50
50
|
include Servus::Support::Rescuer
|
|
51
51
|
include Servus::Events::Emitter
|
|
52
|
+
include Servus::Guards
|
|
52
53
|
|
|
53
54
|
# Support class aliases
|
|
54
55
|
Logger = Servus::Support::Logger
|
|
@@ -185,7 +186,14 @@ module Servus
|
|
|
185
186
|
before_call(args)
|
|
186
187
|
|
|
187
188
|
instance = new(**args)
|
|
188
|
-
|
|
189
|
+
|
|
190
|
+
# Wrap execution in catch block to handle guard failures
|
|
191
|
+
result = catch(:guard_failure) do
|
|
192
|
+
benchmark(**args) { instance.call }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# If result is a GuardError, a guard failed - wrap in failure Response
|
|
196
|
+
result = Response.new(false, nil, result) if result.is_a?(Servus::Support::Errors::GuardError)
|
|
189
197
|
|
|
190
198
|
after_call(result, instance)
|
|
191
199
|
|
data/lib/servus/config.rb
CHANGED
|
@@ -44,14 +44,29 @@ module Servus
|
|
|
44
44
|
# @return [Boolean] true to validate, false to skip validation
|
|
45
45
|
attr_accessor :strict_event_validation
|
|
46
46
|
|
|
47
|
+
# The directory where guard classes are located.
|
|
48
|
+
#
|
|
49
|
+
# Defaults to `Rails.root/app/guards` in Rails applications.
|
|
50
|
+
#
|
|
51
|
+
# @return [String] the guards directory path
|
|
52
|
+
attr_accessor :guards_dir
|
|
53
|
+
|
|
54
|
+
# Whether to include the default built-in guards (EnsurePresent, EnsurePositive).
|
|
55
|
+
#
|
|
56
|
+
# @return [Boolean] true to include default guards, false to exclude them
|
|
57
|
+
attr_accessor :include_default_guards
|
|
58
|
+
|
|
47
59
|
# Initializes a new configuration with default values.
|
|
48
60
|
#
|
|
49
61
|
# @api private
|
|
50
62
|
def initialize
|
|
51
|
-
@
|
|
52
|
-
@
|
|
63
|
+
@guards_dir = 'app/guards'
|
|
64
|
+
@events_dir = 'app/events'
|
|
65
|
+
@schemas_dir = 'app/schemas'
|
|
53
66
|
@services_dir = 'app/services'
|
|
67
|
+
|
|
54
68
|
@strict_event_validation = true
|
|
69
|
+
@include_default_guards = true
|
|
55
70
|
end
|
|
56
71
|
|
|
57
72
|
# Returns the full path to a service's schema file.
|
|
@@ -118,9 +118,6 @@ module Servus
|
|
|
118
118
|
end
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
# Instance methods for emitting events during service execution
|
|
122
|
-
private
|
|
123
|
-
|
|
124
121
|
# Emits events for a specific trigger with the given result.
|
|
125
122
|
#
|
|
126
123
|
# @param trigger [Symbol] the trigger type (:success, :failure, :error!)
|
|
@@ -134,6 +131,9 @@ module Servus
|
|
|
134
131
|
end
|
|
135
132
|
end
|
|
136
133
|
|
|
134
|
+
# Instance methods for emitting events during service execution
|
|
135
|
+
private
|
|
136
|
+
|
|
137
137
|
# Builds the event payload using the configured payload builder or defaults.
|
|
138
138
|
#
|
|
139
139
|
# @param emission [Hash] the emission configuration
|
data/lib/servus/guard.rb
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
# Base class for guards that encapsulate validation logic with rich error responses.
|
|
5
|
+
#
|
|
6
|
+
# Guard classes define reusable validation rules with declarative metadata and
|
|
7
|
+
# localized error messages. They provide a clean, performant alternative to
|
|
8
|
+
# scattering validation logic throughout services.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic guard
|
|
11
|
+
# class SufficientBalanceGuard < Servus::Guard
|
|
12
|
+
# http_status 422
|
|
13
|
+
# error_code 'insufficient_balance'
|
|
14
|
+
#
|
|
15
|
+
# message "Insufficient balance: need %{required}, have %{available}" do
|
|
16
|
+
# {
|
|
17
|
+
# required: amount,
|
|
18
|
+
# available: account.balance
|
|
19
|
+
# }
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# def test(account:, amount:)
|
|
23
|
+
# account.balance >= amount
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @example Using a guard in a service
|
|
28
|
+
# class TransferService < Servus::Base
|
|
29
|
+
# def call
|
|
30
|
+
# enforce_sufficient_balance!(account: from_account, amount: amount)
|
|
31
|
+
# # ... perform transfer ...
|
|
32
|
+
# success(result)
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# @see Servus::Guards
|
|
37
|
+
# @see Servus::Base
|
|
38
|
+
class Guard
|
|
39
|
+
class << self
|
|
40
|
+
# Executes a guard and throws :guard_failure with the guard's error if validation fails.
|
|
41
|
+
#
|
|
42
|
+
# This is the bang (!) execution method that halts execution on failure.
|
|
43
|
+
# The caller is responsible for catching the thrown error and handling it.
|
|
44
|
+
#
|
|
45
|
+
# @param guard_class [Class] the guard class to execute
|
|
46
|
+
# @param kwargs [Hash] keyword arguments for the guard
|
|
47
|
+
# @return [void] returns nil if guard passes
|
|
48
|
+
# @throw [:guard_failure, GuardError] if guard fails
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# Servus::Guard.execute!(EnsurePositive, amount: 100) # passes, returns nil
|
|
52
|
+
# Servus::Guard.execute!(EnsurePositive, amount: -10) # throws :guard_failure
|
|
53
|
+
def execute!(guard_class, **)
|
|
54
|
+
guard = guard_class.new(**)
|
|
55
|
+
return if guard.test(**)
|
|
56
|
+
|
|
57
|
+
throw(:guard_failure, guard.error)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Executes a guard and returns boolean result without throwing.
|
|
61
|
+
#
|
|
62
|
+
# This is the predicate (?) execution method for conditional checks.
|
|
63
|
+
#
|
|
64
|
+
# @param guard_class [Class] the guard class to execute
|
|
65
|
+
# @param kwargs [Hash] keyword arguments for the guard
|
|
66
|
+
# @return [Boolean] true if guard passes, false otherwise
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# Servus::Guard.execute?(EnsurePositive, amount: 100) # => true
|
|
70
|
+
# Servus::Guard.execute?(EnsurePositive, amount: -10) # => false
|
|
71
|
+
def execute?(guard_class, **)
|
|
72
|
+
guard_class.new(**).test(**)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Declares the HTTP status code for API responses.
|
|
76
|
+
#
|
|
77
|
+
# @param status [Integer] the HTTP status code
|
|
78
|
+
# @return [void]
|
|
79
|
+
#
|
|
80
|
+
# @example
|
|
81
|
+
# class MyGuard < Servus::Guard
|
|
82
|
+
# http_status 422
|
|
83
|
+
# end
|
|
84
|
+
def http_status(status)
|
|
85
|
+
@http_status_code = status
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns the HTTP status code.
|
|
89
|
+
#
|
|
90
|
+
# @return [Integer, nil] the HTTP status code or nil if not set
|
|
91
|
+
attr_reader :http_status_code
|
|
92
|
+
|
|
93
|
+
# Declares the error code for API responses.
|
|
94
|
+
#
|
|
95
|
+
# @param code [String] the error code
|
|
96
|
+
# @return [void]
|
|
97
|
+
#
|
|
98
|
+
# @example
|
|
99
|
+
# class MyGuard < Servus::Guard
|
|
100
|
+
# error_code 'insufficient_balance'
|
|
101
|
+
# end
|
|
102
|
+
def error_code(code)
|
|
103
|
+
@error_code_value = code
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns the error code.
|
|
107
|
+
#
|
|
108
|
+
# @return [String, nil] the error code or nil if not set
|
|
109
|
+
attr_reader :error_code_value
|
|
110
|
+
|
|
111
|
+
# Declares the message template and data block.
|
|
112
|
+
#
|
|
113
|
+
# The template can be a String (static or with %{} interpolation),
|
|
114
|
+
# a Symbol (I18n key), a Proc (dynamic), or a Hash (inline translations).
|
|
115
|
+
#
|
|
116
|
+
# The block provides data for message interpolation and is evaluated
|
|
117
|
+
# in the guard instance's context.
|
|
118
|
+
#
|
|
119
|
+
# @param template [String, Symbol, Proc, Hash] the message template
|
|
120
|
+
# @yield block that returns a Hash of interpolation data
|
|
121
|
+
# @return [void]
|
|
122
|
+
#
|
|
123
|
+
# @example With string template
|
|
124
|
+
# message "Balance must be at least %{minimum}" do
|
|
125
|
+
# { minimum: 100 }
|
|
126
|
+
# end
|
|
127
|
+
#
|
|
128
|
+
# @example With I18n key
|
|
129
|
+
# message :insufficient_balance do
|
|
130
|
+
# { required: amount, available: account.balance }
|
|
131
|
+
# end
|
|
132
|
+
def message(template, &block)
|
|
133
|
+
@message_template = template
|
|
134
|
+
@message_block = block if block_given?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Returns the message template.
|
|
138
|
+
#
|
|
139
|
+
# @return [String, Symbol, Proc, Hash, nil] the message template
|
|
140
|
+
attr_reader :message_template
|
|
141
|
+
|
|
142
|
+
# Returns the message data block.
|
|
143
|
+
#
|
|
144
|
+
# @return [Proc, nil] the message data block
|
|
145
|
+
attr_reader :message_block
|
|
146
|
+
|
|
147
|
+
# Hook called when a class inherits from Guard.
|
|
148
|
+
#
|
|
149
|
+
# Automatically defines guard methods on the Servus::Guards module.
|
|
150
|
+
#
|
|
151
|
+
# @param subclass [Class] the inheriting class
|
|
152
|
+
# @return [void]
|
|
153
|
+
# @api private
|
|
154
|
+
def inherited(subclass)
|
|
155
|
+
super
|
|
156
|
+
register_guard_methods(subclass)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Defines bang and predicate methods on Servus::Guards for the guard class.
|
|
160
|
+
#
|
|
161
|
+
# Creates two methods:
|
|
162
|
+
# - enforce_<name>! (throws :guard_failure on validation failure)
|
|
163
|
+
# - check_<name>? (returns boolean)
|
|
164
|
+
#
|
|
165
|
+
# @param guard_class [Class] the guard class to register
|
|
166
|
+
# @return [void]
|
|
167
|
+
# @api private
|
|
168
|
+
#
|
|
169
|
+
# @example
|
|
170
|
+
# # For SufficientBalanceGuard, creates:
|
|
171
|
+
# # enforce_sufficient_balance!(account:, amount:)
|
|
172
|
+
# # check_sufficient_balance?(account:, amount:)
|
|
173
|
+
def register_guard_methods(guard_class)
|
|
174
|
+
return unless guard_class.name
|
|
175
|
+
|
|
176
|
+
base_name = derive_method_name(guard_class)
|
|
177
|
+
|
|
178
|
+
# Define bang method (throws on failure)
|
|
179
|
+
Servus::Guards.define_method("enforce_#{base_name}!") do |**kwargs|
|
|
180
|
+
Servus::Guard.execute!(guard_class, **kwargs)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Define predicate method (returns boolean)
|
|
184
|
+
Servus::Guards.define_method("check_#{base_name}?") do |**kwargs|
|
|
185
|
+
Servus::Guard.execute?(guard_class, **kwargs)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Converts a guard class name to a method name.
|
|
190
|
+
#
|
|
191
|
+
# Strips the 'Guard' suffix and converts to snake_case.
|
|
192
|
+
# The resulting name is used with 'enforce_' and 'check_' prefixes.
|
|
193
|
+
#
|
|
194
|
+
# @param guard_class [Class] the guard class
|
|
195
|
+
# @return [String] the base method name (without enforce_/check_ prefix or ! or ?)
|
|
196
|
+
# @api private
|
|
197
|
+
#
|
|
198
|
+
# @example
|
|
199
|
+
# derive_method_name(SufficientBalanceGuard) # => "sufficient_balance"
|
|
200
|
+
# derive_method_name(PresenceGuard) # => "presence"
|
|
201
|
+
def derive_method_name(guard_class)
|
|
202
|
+
class_name = guard_class.name.split('::').last
|
|
203
|
+
class_name.gsub(/Guard$/, '')
|
|
204
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
205
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
206
|
+
.downcase
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
attr_reader :kwargs
|
|
211
|
+
|
|
212
|
+
# Initializes a new guard instance with the provided arguments.
|
|
213
|
+
#
|
|
214
|
+
# @param kwargs [Hash] keyword arguments for the guard
|
|
215
|
+
def initialize(**kwargs)
|
|
216
|
+
@kwargs = kwargs
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Tests whether the guard passes.
|
|
220
|
+
#
|
|
221
|
+
# Subclasses must implement this method with explicit keyword arguments
|
|
222
|
+
# that define the guard's contract.
|
|
223
|
+
#
|
|
224
|
+
# @return [Boolean] true if the guard passes, false otherwise
|
|
225
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
|
226
|
+
#
|
|
227
|
+
# @example
|
|
228
|
+
# def test(account:, amount:)
|
|
229
|
+
# account.balance >= amount
|
|
230
|
+
# end
|
|
231
|
+
def test
|
|
232
|
+
raise NotImplementedError, "#{self.class} must implement #test"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Returns the formatted error message.
|
|
236
|
+
#
|
|
237
|
+
# Uses {Servus::Support::MessageResolver} to resolve the template and
|
|
238
|
+
# interpolate data from the message block.
|
|
239
|
+
#
|
|
240
|
+
# @return [String] the formatted error message
|
|
241
|
+
# @see Servus::Support::MessageResolver
|
|
242
|
+
def message
|
|
243
|
+
Servus::Support::MessageResolver.new(
|
|
244
|
+
template: self.class.message_template,
|
|
245
|
+
data: self.class.message_block ? instance_exec(&self.class.message_block) : {},
|
|
246
|
+
i18n_scope: 'guards'
|
|
247
|
+
).resolve(context: self)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Returns a GuardError instance configured with this guard's metadata.
|
|
251
|
+
#
|
|
252
|
+
# Called when a guard fails to create the error that gets thrown.
|
|
253
|
+
# The caller decides how to handle the error (e.g., wrap in a failure response).
|
|
254
|
+
#
|
|
255
|
+
# @return [Servus::Support::Errors::GuardError] the error instance
|
|
256
|
+
def error
|
|
257
|
+
Servus::Support::Errors::GuardError.new(
|
|
258
|
+
message,
|
|
259
|
+
code: self.class.error_code_value || 'validation_failed',
|
|
260
|
+
http_status: self.class.http_status_code || 422
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Provides convenience access to kwargs as methods.
|
|
265
|
+
#
|
|
266
|
+
# This allows the message data block to access parameters directly
|
|
267
|
+
# (e.g., `amount` instead of `kwargs[:amount]`).
|
|
268
|
+
#
|
|
269
|
+
# @param method_name [Symbol] the method name
|
|
270
|
+
# @param args [Array] method arguments
|
|
271
|
+
# @param block [Proc] method block
|
|
272
|
+
# @return [Object] the value from kwargs
|
|
273
|
+
# @raise [NoMethodError] if the method is not found
|
|
274
|
+
# @api private
|
|
275
|
+
def method_missing(method_name, *args, &)
|
|
276
|
+
kwargs[method_name] || super
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Checks if the guard responds to a method.
|
|
280
|
+
#
|
|
281
|
+
# @param method_name [Symbol] the method name
|
|
282
|
+
# @param include_private [Boolean] whether to include private methods
|
|
283
|
+
# @return [Boolean] true if the method exists or is in kwargs
|
|
284
|
+
# @api private
|
|
285
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
286
|
+
kwargs.key?(method_name) || super
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Guards
|
|
5
|
+
# Guard that ensures all specified attributes on an object are falsey.
|
|
6
|
+
#
|
|
7
|
+
# @example Single attribute
|
|
8
|
+
# enforce_falsey!(on: user, check: :banned)
|
|
9
|
+
#
|
|
10
|
+
# @example Multiple attributes (all must be falsey)
|
|
11
|
+
# enforce_falsey!(on: post, check: [:deleted, :hidden, :flagged])
|
|
12
|
+
#
|
|
13
|
+
# @example Conditional check
|
|
14
|
+
# if check_falsey?(on: user, check: :suspended)
|
|
15
|
+
# # user is not suspended
|
|
16
|
+
# end
|
|
17
|
+
class FalseyGuard < Servus::Guard
|
|
18
|
+
http_status 422
|
|
19
|
+
error_code 'must_be_falsey'
|
|
20
|
+
|
|
21
|
+
message '%<class_name>s.%<failed_attr>s must be falsey (got %<value>s)' do
|
|
22
|
+
message_data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Tests whether all specified attributes are falsey.
|
|
26
|
+
#
|
|
27
|
+
# @param on [Object] the object to check
|
|
28
|
+
# @param check [Symbol, Array<Symbol>] attribute(s) to verify
|
|
29
|
+
# @return [Boolean] true if all attributes are falsey
|
|
30
|
+
def test(on:, check:)
|
|
31
|
+
Array(check).all? { |attr| !on.public_send(attr) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Builds the interpolation data for the error message.
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash] message interpolation data
|
|
39
|
+
def message_data
|
|
40
|
+
object = kwargs[:on]
|
|
41
|
+
failed = find_failing_attribute(object, Array(kwargs[:check]))
|
|
42
|
+
{
|
|
43
|
+
class_name: object.class.name,
|
|
44
|
+
failed_attr: failed,
|
|
45
|
+
value: object.public_send(failed).inspect
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Finds the first attribute that fails the falsey check.
|
|
50
|
+
#
|
|
51
|
+
# @param object [Object] the object to check
|
|
52
|
+
# @param checks [Array<Symbol>] attributes to check
|
|
53
|
+
# @return [Symbol] the first failing attribute
|
|
54
|
+
def find_failing_attribute(object, checks)
|
|
55
|
+
checks.find { |attr| !!object.public_send(attr) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Guards
|
|
5
|
+
# Guard that ensures all provided values are present (not nil or empty).
|
|
6
|
+
#
|
|
7
|
+
# This is a flexible guard that accepts any number of keyword arguments
|
|
8
|
+
# and validates that all values are present.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# class MyService < Servus::Base
|
|
12
|
+
# def call
|
|
13
|
+
# enforce_presence!(user: user, account: account)
|
|
14
|
+
# # ...
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Single value
|
|
19
|
+
# enforce_presence!(email: email)
|
|
20
|
+
#
|
|
21
|
+
# @example Multiple values
|
|
22
|
+
# enforce_presence!(user: user, account: account, device: device)
|
|
23
|
+
#
|
|
24
|
+
# @example Conditional check
|
|
25
|
+
# if check_presence?(user: user)
|
|
26
|
+
# # user is present
|
|
27
|
+
# end
|
|
28
|
+
class PresenceGuard < Servus::Guard
|
|
29
|
+
http_status 422
|
|
30
|
+
error_code 'must_be_present'
|
|
31
|
+
|
|
32
|
+
message '%<key>s must be present (got %<value>s)' do
|
|
33
|
+
message_data
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Tests whether all provided values are present.
|
|
37
|
+
#
|
|
38
|
+
# A value is considered present if it is not nil and not empty
|
|
39
|
+
# (for values that respond to empty?).
|
|
40
|
+
#
|
|
41
|
+
# @param values [Hash] keyword arguments to validate
|
|
42
|
+
# @return [Boolean] true if all values are present
|
|
43
|
+
def test(**values)
|
|
44
|
+
values.all? { |_, value| present?(value) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Builds the interpolation data for the error message.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash] message interpolation data
|
|
52
|
+
def message_data
|
|
53
|
+
failed_key, failed_value = find_failing_entry
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
key: failed_key,
|
|
57
|
+
value: failed_value.inspect
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Finds the first key-value pair that fails the presence check.
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<Symbol, Object>] the failing key and value
|
|
64
|
+
def find_failing_entry
|
|
65
|
+
kwargs.find { |_, value| !present?(value) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Checks if a value is present (not nil and not empty).
|
|
69
|
+
#
|
|
70
|
+
# @param value [Object] the value to check
|
|
71
|
+
# @return [Boolean] true if present
|
|
72
|
+
def present?(value)
|
|
73
|
+
return false if value.nil?
|
|
74
|
+
return !value.empty? if value.respond_to?(:empty?)
|
|
75
|
+
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Guards
|
|
5
|
+
# Guard that ensures an attribute matches an expected value or one of several allowed values.
|
|
6
|
+
#
|
|
7
|
+
# @example Single expected value
|
|
8
|
+
# enforce_state!(on: order, check: :status, is: :pending)
|
|
9
|
+
#
|
|
10
|
+
# @example Multiple allowed values (any match passes)
|
|
11
|
+
# enforce_state!(on: account, check: :status, is: [:active, :trial])
|
|
12
|
+
#
|
|
13
|
+
# @example Conditional check
|
|
14
|
+
# if check_state?(on: order, check: :status, is: :shipped)
|
|
15
|
+
# # order is shipped
|
|
16
|
+
# end
|
|
17
|
+
class StateGuard < Servus::Guard
|
|
18
|
+
http_status 422
|
|
19
|
+
error_code 'invalid_state'
|
|
20
|
+
|
|
21
|
+
message '%<class_name>s.%<attr>s must be %<expected>s (got %<actual>s)' do
|
|
22
|
+
message_data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Tests whether the attribute matches the expected value(s).
|
|
26
|
+
#
|
|
27
|
+
# @param on [Object] the object to check
|
|
28
|
+
# @param check [Symbol] the attribute to verify
|
|
29
|
+
# @param is [Object, Array] expected value(s) - passes if attribute matches any
|
|
30
|
+
# @return [Boolean] true if attribute matches expected value(s)
|
|
31
|
+
def test(on:, check:, is:) # rubocop:disable Naming/MethodParameterName
|
|
32
|
+
Array(is).include?(on.public_send(check))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Builds the interpolation data for the error message.
|
|
38
|
+
#
|
|
39
|
+
# @return [Hash] message interpolation data
|
|
40
|
+
def message_data
|
|
41
|
+
object = kwargs[:on]
|
|
42
|
+
attr = kwargs[:check]
|
|
43
|
+
expected = kwargs[:is]
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
attr: attr,
|
|
47
|
+
class_name: object.class.name,
|
|
48
|
+
actual: object.public_send(attr),
|
|
49
|
+
expected: format_expected(expected)
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Formats the expected value(s) for the error message.
|
|
54
|
+
#
|
|
55
|
+
# @param expected [Object, Array] the expected value(s)
|
|
56
|
+
# @return [String] formatted expected value(s)
|
|
57
|
+
def format_expected(expected)
|
|
58
|
+
expected.is_a?(Array) ? "one of #{expected.join(', ')}" : expected.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|