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.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Guards
5
+ # Guard that ensures all specified attributes on an object are truthy.
6
+ #
7
+ # @example Single attribute
8
+ # enforce_truthy!(on: user, check: :active)
9
+ #
10
+ # @example Multiple attributes (all must be truthy)
11
+ # enforce_truthy!(on: user, check: [:active, :verified, :confirmed])
12
+ #
13
+ # @example Conditional check
14
+ # if check_truthy?(on: subscription, check: :valid?)
15
+ # # subscription is valid
16
+ # end
17
+ class TruthyGuard < Servus::Guard
18
+ http_status 422
19
+ error_code 'must_be_truthy'
20
+
21
+ message '%<class_name>s.%<failed_attr>s must be truthy (got %<value>s)' do
22
+ message_data
23
+ end
24
+
25
+ # Tests whether all specified attributes are truthy.
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 truthy
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
+ check = kwargs[:check]
42
+ failed = find_failing_attribute(object, Array(check))
43
+
44
+ {
45
+ failed_attr: failed,
46
+ class_name: object.class.name,
47
+ value: object.public_send(failed).inspect
48
+ }
49
+ end
50
+
51
+ # Finds the first attribute that fails the truthy check.
52
+ #
53
+ # @param object [Object] the object to check
54
+ # @param checks [Array<Symbol>] attributes to check
55
+ # @return [Symbol] the first failing attribute
56
+ def find_failing_attribute(object, checks)
57
+ checks.find { |attr| !object.public_send(attr) }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ # Module providing guard functionality to Servus services.
5
+ #
6
+ # Guard methods are defined directly on this module when guard classes
7
+ # inherit from Servus::Guard. The inherited hook triggers method definition,
8
+ # so no registry or method_missing is needed.
9
+ #
10
+ # @example Using guards in a service
11
+ # class TransferService < Servus::Base
12
+ # def call
13
+ # enforce_presence!(user: user, account: account)
14
+ # enforce_state!(on: account, check: :status, is: :active)
15
+ # # ... perform transfer ...
16
+ # success(result)
17
+ # end
18
+ # end
19
+ #
20
+ # @see Servus::Guard
21
+ module Guards
22
+ # Guard methods are defined dynamically via Servus::Guard.inherited
23
+ # when guard classes are loaded. Each guard class defines:
24
+ # - enforce_<name>! (throws :guard_failure on failure)
25
+ # - check_<name>? (returns boolean)
26
+
27
+ class << self
28
+ # Loads default guards if configured.
29
+ #
30
+ # Called after Guards module is defined to load built-in guards
31
+ # when Servus.config.include_default_guards is true.
32
+ #
33
+ # @return [void]
34
+ # @api private
35
+ def load_defaults
36
+ return unless Servus.config.include_default_guards
37
+
38
+ require_relative 'guards/presence_guard'
39
+ require_relative 'guards/truthy_guard'
40
+ require_relative 'guards/falsey_guard'
41
+ require_relative 'guards/state_guard'
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ # Load default guards based on configuration
48
+ Servus::Guards.load_defaults
@@ -14,16 +14,12 @@ module Servus
14
14
  # end
15
15
  #
16
16
  # @see #run_service
17
- # @see #render_service_object_error
17
+ # @see #render_service_error
18
18
  module ControllerHelpers
19
19
  # Executes a service and handles success/failure automatically.
20
20
  #
21
- # This method runs the service with the provided parameters. On success,
22
- # it stores the result in @result for use in views. On failure, it
23
- # automatically calls {#render_service_object_error} with the error details.
24
- #
25
- # The result is always stored in the @result instance variable, making it
26
- # available in views for rendering successful responses.
21
+ # On success, stores the result in @result for use in views.
22
+ # On failure, renders the error as JSON with the appropriate HTTP status.
27
23
  #
28
24
  # @param klass [Class] service class to execute (must inherit from {Servus::Base})
29
25
  # @param params [Hash] keyword arguments to pass to the service
@@ -33,69 +29,45 @@ module Servus
33
29
  # class UsersController < ApplicationController
34
30
  # def create
35
31
  # run_service Services::CreateUser::Service, user_params
36
- # # If successful, @result is available for rendering
37
- # # If failed, error response is automatically rendered
38
32
  # end
39
33
  # end
40
34
  #
41
- # @example Using @result in views
42
- # # In your Jbuilder view (create.json.jbuilder)
43
- # json.user do
44
- # json.id @result.data[:user_id]
45
- # json.email @result.data[:email]
46
- # end
47
- #
48
- # @example Manual success handling
49
- # class UsersController < ApplicationController
50
- # def create
51
- # run_service Services::CreateUser::Service, user_params
52
- # return unless @result.success?
53
- #
54
- # # Custom success handling
55
- # redirect_to user_path(@result.data[:user_id])
56
- # end
57
- # end
58
- #
59
- # @see #render_service_object_error
35
+ # @see #render_service_error
60
36
  # @see Servus::Base.call
61
37
  def run_service(klass, params)
62
38
  @result = klass.call(**params)
63
- render_service_object_error(@result.error.api_error) unless @result.success?
39
+ render_service_error(@result.error) unless @result.success?
64
40
  end
65
41
 
66
42
  # Renders a service error as a JSON response.
67
43
  #
68
- # This method is called automatically by {#run_service} when a service fails,
69
- # but can also be called manually for custom error handling. It renders the
70
- # error's api_error hash with the appropriate HTTP status code.
44
+ # Uses error.http_status for the response status code and
45
+ # error.api_error for the response body.
71
46
  #
72
47
  # Override this method in your controller to customize error response format.
73
48
  #
74
- # @param api_error [Hash] error hash with :code and :message keys from {Servus::Support::Errors::ServiceError#api_error}
49
+ # @param error [Servus::Support::Errors::ServiceError] the error to render
75
50
  # @return [void]
76
51
  #
77
52
  # @example Default behavior
78
- # # Renders: { code: :not_found, message: "User not found" }
53
+ # # Renders: { error: { code: :not_found, message: "User not found" } }
79
54
  # # With status: 404
80
- # render_service_object_error(result.error.api_error)
81
55
  #
82
56
  # @example Custom error rendering
83
- # class ApplicationController < ActionController::Base
84
- # def render_service_object_error(api_error)
85
- # render json: {
86
- # error: {
87
- # type: api_error[:code],
88
- # details: api_error[:message],
89
- # timestamp: Time.current
90
- # }
91
- # }, status: api_error[:code]
92
- # end
57
+ # def render_service_error(error)
58
+ # render json: {
59
+ # error: {
60
+ # type: error.api_error[:code],
61
+ # details: error.message,
62
+ # timestamp: Time.current
63
+ # }
64
+ # }, status: error.http_status
93
65
  # end
94
66
  #
95
67
  # @see Servus::Support::Errors::ServiceError#api_error
96
- # @see #run_service
97
- def render_service_object_error(api_error)
98
- render json: api_error, status: api_error[:code]
68
+ # @see Servus::Support::Errors::ServiceError#http_status
69
+ def render_service_error(error)
70
+ render json: { error: error.api_error }, status: error.http_status
99
71
  end
100
72
  end
101
73
  end
@@ -19,14 +19,22 @@ module Servus
19
19
  end
20
20
  end
21
21
 
22
- # Load event handlers and clear on reload
22
+ # Load guards and event handlers, clear caches on reload
23
23
  config.to_prepare do
24
+ # Load custom guards from guards_dir
25
+ guards_path = Rails.root.join(Servus.config.guards_dir)
26
+ if Dir.exist?(guards_path)
27
+ Dir[File.join(guards_path, '**/*_guard.rb')].each do |file|
28
+ require_dependency file
29
+ end
30
+ end
31
+
24
32
  Servus::Events::Bus.clear if Rails.env.development?
25
33
 
26
34
  # Eager load all event handlers
27
35
  events_path = Rails.root.join(Servus.config.events_dir)
28
- Dir[File.join(events_path, '**/*_handler.rb')].each do |handler_file|
29
- require_dependency handler_file
36
+ Dir[File.join(events_path, '**/*_handler.rb')].each do |file|
37
+ require_dependency file
30
38
  end
31
39
  end
32
40
 
@@ -4,31 +4,31 @@ module Servus
4
4
  module Support
5
5
  # Contains all error classes used by Servus services.
6
6
  #
7
- # All error classes inherit from {ServiceError} and provide both a human-readable
8
- # message and an API-friendly error response via {ServiceError#api_error}.
7
+ # All error classes inherit from {ServiceError} and provide:
8
+ # - {ServiceError#http_status} for the HTTP response status
9
+ # - {ServiceError#api_error} for the JSON response body
9
10
  #
10
11
  # @see ServiceError
11
12
  module Errors
12
13
  # Base error class for all Servus service errors.
13
14
  #
14
- # This class provides the foundation for all service-related errors, including:
15
- # - Default error messages via DEFAULT_MESSAGE constant
16
- # - API-friendly error responses via {#api_error}
17
- # - Automatic message fallback to default if none provided
15
+ # Subclasses define their HTTP status via {#http_status} and their
16
+ # API response format via {#api_error}.
18
17
  #
19
18
  # @example Creating a custom error type
20
- # class MyCustomError < Servus::Support::Errors::ServiceError
21
- # DEFAULT_MESSAGE = 'Something went wrong'
19
+ # class InsufficientFundsError < Servus::Support::Errors::ServiceError
20
+ # DEFAULT_MESSAGE = 'Insufficient funds'
21
+ #
22
+ # def http_status = :unprocessable_entity
22
23
  #
23
24
  # def api_error
24
- # { code: :custom_error, message: message }
25
+ # { code: 'insufficient_funds', message: message }
25
26
  # end
26
27
  # end
27
28
  #
28
29
  # @example Using with failure method
29
30
  # def call
30
- # return failure("User not found", type: Servus::Support::Errors::NotFoundError)
31
- # # ...
31
+ # return failure("User not found", type: NotFoundError)
32
32
  # end
33
33
  class ServiceError < StandardError
34
34
  attr_reader :message
@@ -38,188 +38,117 @@ module Servus
38
38
  # Creates a new service error instance.
39
39
  #
40
40
  # @param message [String, nil] custom error message (uses DEFAULT_MESSAGE if nil)
41
- # @return [ServiceError] the error instance
42
- #
43
- # @example With custom message
44
- # error = ServiceError.new("Something went wrong")
45
- # error.message # => "Something went wrong"
46
- #
47
- # @example With default message
48
- # error = ServiceError.new
49
- # error.message # => "An error occurred"
50
41
  def initialize(message = nil)
51
42
  @message = message || self.class::DEFAULT_MESSAGE
52
- super("#{self.class}: #{message}")
43
+ super("#{self.class}: #{@message}")
53
44
  end
54
45
 
55
- # Returns an API-friendly error response.
46
+ # Returns the HTTP status code for this error.
56
47
  #
57
- # This method formats the error for API responses, providing both a
58
- # symbolic code and the error message. Override in subclasses to customize
59
- # the error code for specific HTTP status codes.
48
+ # @return [Symbol] Rails-compatible status symbol
49
+ def http_status = :bad_request
50
+
51
+ # Returns an API-friendly error response.
60
52
  #
61
53
  # @return [Hash] hash with :code and :message keys
62
- #
63
- # @example
64
- # error = ServiceError.new("Failed to process")
65
- # error.api_error # => { code: :bad_request, message: "Failed to process" }
66
- def api_error
67
- { code: :bad_request, message: message }
68
- end
54
+ def api_error = { code: http_status, message: message }
69
55
  end
70
56
 
71
- # Represents a 400 Bad Request error.
72
- #
73
- # Use this error when the client sends malformed or invalid request data.
74
- #
75
- # @example
76
- # def call
77
- # return failure("Invalid JSON format", type: BadRequestError)
78
- # end
57
+ # 400 Bad Request - malformed or invalid request data.
79
58
  class BadRequestError < ServiceError
80
59
  DEFAULT_MESSAGE = 'Bad request'
81
60
 
82
- # 400 error response
83
- # @return [Hash] The error response
84
- def api_error
85
- { code: :bad_request, message: message }
86
- end
61
+ def http_status = :bad_request
62
+ def api_error = { code: http_status, message: message }
87
63
  end
88
64
 
89
- # Represents a 401 Unauthorized error for authentication failures.
90
- #
91
- # Use this error when authentication credentials are missing, invalid, or expired.
92
- #
93
- # @example
94
- # def call
95
- # return failure("Invalid API key", type: AuthenticationError) unless valid_api_key?
96
- # end
65
+ # 401 Unauthorized - authentication credentials missing or invalid.
97
66
  class AuthenticationError < ServiceError
98
67
  DEFAULT_MESSAGE = 'Authentication failed'
99
68
 
100
- # @return [Hash] API error response with :unauthorized code
101
- def api_error
102
- { code: :unauthorized, message: message }
103
- end
69
+ def http_status = :unauthorized
70
+ def api_error = { code: http_status, message: message }
104
71
  end
105
72
 
106
- # Represents a 401 Unauthorized error (alias for AuthenticationError).
107
- #
108
- # Use this error for authorization failures when credentials are valid but
109
- # lack sufficient permissions.
110
- #
111
- # @example
112
- # def call
113
- # return failure("Access denied", type: UnauthorizedError) unless user.admin?
114
- # end
73
+ # 401 Unauthorized (alias for AuthenticationError).
115
74
  class UnauthorizedError < AuthenticationError
116
75
  DEFAULT_MESSAGE = 'Unauthorized'
117
76
  end
118
77
 
119
- # Represents a 403 Forbidden error.
120
- #
121
- # Use this error when the user is authenticated but not authorized to perform
122
- # the requested action.
123
- #
124
- # @example
125
- # def call
126
- # return failure("Insufficient permissions", type: ForbiddenError) unless can_access?
127
- # end
78
+ # 403 Forbidden - authenticated but not authorized.
128
79
  class ForbiddenError < ServiceError
129
80
  DEFAULT_MESSAGE = 'Forbidden'
130
81
 
131
- # 403 error response
132
- # @return [Hash] The error response
133
- def api_error
134
- { code: :forbidden, message: message }
135
- end
82
+ def http_status = :forbidden
83
+ def api_error = { code: http_status, message: message }
136
84
  end
137
85
 
138
- # Represents a 404 Not Found error.
139
- #
140
- # Use this error when a requested resource cannot be found.
141
- #
142
- # @example
143
- # def call
144
- # user = User.find_by(id: @user_id)
145
- # return failure("User not found", type: NotFoundError) unless user
146
- # end
86
+ # 404 Not Found - requested resource does not exist.
147
87
  class NotFoundError < ServiceError
148
88
  DEFAULT_MESSAGE = 'Not found'
149
89
 
150
- # @return [Hash] API error response with :not_found code
151
- def api_error
152
- { code: :not_found, message: message }
153
- end
90
+ def http_status = :not_found
91
+ def api_error = { code: http_status, message: message }
154
92
  end
155
93
 
156
- # Represents a 422 Unprocessable Entity error.
157
- #
158
- # Use this error when the request is well-formed but contains semantic errors
159
- # that prevent processing (e.g., business logic violations).
160
- #
161
- # @example
162
- # def call
163
- # return failure("Order already shipped", type: UnprocessableEntityError) if @order.shipped?
164
- # end
94
+ # 422 Unprocessable Entity - semantic errors in request.
165
95
  class UnprocessableEntityError < ServiceError
166
96
  DEFAULT_MESSAGE = 'Unprocessable entity'
167
97
 
168
- # @return [Hash] API error response with :unprocessable_entity code
169
- def api_error
170
- { code: :unprocessable_entity, message: message }
171
- end
98
+ def http_status = :unprocessable_entity
99
+ def api_error = { code: http_status, message: message }
172
100
  end
173
101
 
174
- # Represents validation failures (inherits 422 status).
175
- #
176
- # Automatically raised by the framework when schema validation fails.
177
- # Can also be used for custom validation errors.
178
- #
179
- # @example
180
- # def call
181
- # return failure("Email format invalid", type: ValidationError) unless valid_email?
182
- # end
102
+ # 422 Validation Error - schema or business validation failed.
183
103
  class ValidationError < UnprocessableEntityError
184
104
  DEFAULT_MESSAGE = 'Validation failed'
105
+
106
+ def api_error = { code: http_status, message: message }
185
107
  end
186
108
 
187
- # Represents a 500 Internal Server Error.
109
+ # Guard validation failure with custom code.
188
110
  #
189
- # Use this error for unexpected server-side failures.
111
+ # Guards define their own error code and HTTP status via the DSL.
190
112
  #
191
113
  # @example
192
- # def call
193
- # return failure("Database connection lost", type: InternalServerError) if db_down?
194
- # end
114
+ # GuardError.new("Amount must be positive", code: 'invalid_amount', http_status: 422)
115
+ class GuardError < ServiceError
116
+ DEFAULT_MESSAGE = 'Guard validation failed'
117
+
118
+ # @return [String] application-specific error code
119
+ attr_reader :code
120
+
121
+ # @return [Symbol, Integer] HTTP status code
122
+ attr_reader :http_status
123
+
124
+ # Creates a new guard error with metadata.
125
+ #
126
+ # @param message [String, nil] error message
127
+ # @param code [String] error code for API responses (default: 'guard_failed')
128
+ # @param http_status [Symbol, Integer] HTTP status (default: :unprocessable_entity)
129
+ def initialize(message = nil, code: 'guard_failed', http_status: :unprocessable_entity)
130
+ super(message)
131
+ @code = code
132
+ @http_status = http_status
133
+ end
134
+
135
+ def api_error = { code: code, message: message }
136
+ end
137
+
138
+ # 500 Internal Server Error - unexpected server-side failure.
195
139
  class InternalServerError < ServiceError
196
140
  DEFAULT_MESSAGE = 'Internal server error'
197
141
 
198
- # @return [Hash] API error response with :internal_server_error code
199
- def api_error
200
- { code: :internal_server_error, message: message }
201
- end
142
+ def http_status = :internal_server_error
143
+ def api_error = { code: http_status, message: message }
202
144
  end
203
145
 
204
- # Represents a 503 Service Unavailable error.
205
- #
206
- # Use this error when a service dependency is temporarily unavailable.
207
- #
208
- # @example Using with rescue_from
209
- # class MyService < Servus::Base
210
- # rescue_from Net::HTTPError, use: ServiceUnavailableError
211
- #
212
- # def call
213
- # make_external_api_call
214
- # end
215
- # end
146
+ # 503 Service Unavailable - dependency temporarily unavailable.
216
147
  class ServiceUnavailableError < ServiceError
217
148
  DEFAULT_MESSAGE = 'Service unavailable'
218
149
 
219
- # @return [Hash] API error response with :service_unavailable code
220
- def api_error
221
- { code: :service_unavailable, message: message }
222
- end
150
+ def http_status = :service_unavailable
151
+ def api_error = { code: http_status, message: message }
223
152
  end
224
153
  end
225
154
  end