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.
@@ -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
- result = benchmark(**args) { instance.call }
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
- @events_dir = 'app/events'
52
- @schemas_dir = 'app/schemas'
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.
@@ -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