servus 1.0.1 → 1.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: 8da8eeaf0d61216b5df16e50dbb6a7cf0fd36bc8a9a2c5d59f233a5ba6d399a8
4
- data.tar.gz: ba37edced59e89519122e27dbee45334edadd1b35cda36b03f7966fe3452a0c9
3
+ metadata.gz: 10e4e061222d303e5d694d816afd1d0557eb6c4e7804dd5b454e48cddcdf0797
4
+ data.tar.gz: b6cdb1fcabe827988e6e8daf44d20d2da2649cf4b252d36ac68dd1f8f14db584
5
5
  SHA512:
6
- metadata.gz: 5b7969e87edf801cc7c4e5e452db45a4bedb90f11b8bafb24e4750843a9babf80ca0b6d00d2a8abad6ce046192165cf17b50fe405b952d8324cdc3d04b131aed
7
- data.tar.gz: 5485a32d587becac5ad967c3e552396c1b6994263dbe7a1c977e33d0a290c391dd748f02a3455a2e45307658932a1b66b51b9448ac6d8bcc92b96c0ce243ae55
6
+ metadata.gz: 61ab96b3ed1da09eb768ced10cbacc5cb6f4e8a8f4aead7ed57aa6f49dbdd016513dee1f1f2402a7a21504913b9ae5c0ef23298301fc398c3dcb6f224d2dcedc
7
+ data.tar.gz: 011eaaabc88c46604d60c9f82727e43f6152521a7f1cb64b483a1b912eb0a047eaf8607eab31b51f31a92df90333c4130af845f10a8d7263aa3bcfd3fda85f60
data/lib/servus/base.rb CHANGED
@@ -115,6 +115,40 @@ module Servus
115
115
  Response = Servus::Support::Response
116
116
  Validator = Servus::Support::Validator
117
117
 
118
+ class << self
119
+ # Assigns the logger Servus writes this service's call, outcome, and
120
+ # error lines through. It covers this class and every service below it.
121
+ #
122
+ # Build the logger from {Servus.logger} so it keeps the appenders and
123
+ # formatting the app configured, rather than replacing them.
124
+ #
125
+ # Assigning +nil+ returns the class to the logger it inherits.
126
+ #
127
+ # @param logger [::Logger, nil]
128
+ #
129
+ # @example Giving an engine's services their own logger
130
+ # class Treasury::ApplicationService < Servus::Base
131
+ # self.logger = SemanticLogger[Treasury]
132
+ # end
133
+ attr_writer :logger
134
+
135
+ # The logger for this service class: its own, else the one it inherits,
136
+ # else {Servus.logger}.
137
+ #
138
+ # @return [::Logger]
139
+ def logger
140
+ return @logger if @logger
141
+
142
+ superclass <= Servus::Base ? superclass.logger : Servus.logger
143
+ end
144
+ end
145
+
146
+ # @return [::Logger] the logger of this service's class
147
+ # @see .logger
148
+ def logger
149
+ self.class.logger
150
+ end
151
+
118
152
  # Creates a successful response with the provided data.
119
153
  #
120
154
  # Use this method to return successful results from your service's call method.
@@ -182,6 +216,46 @@ module Servus
182
216
  Response.new(false, data, error)
183
217
  end
184
218
 
219
+ # Ends the service with a failure response, from anywhere inside +call+.
220
+ #
221
+ # Builds the same response as {#failure} and returns it from the service as
222
+ # if +call+ had returned it, so it is logged, validated against the failure
223
+ # schema, and emits events exactly as a returned failure does. Unlike
224
+ # {#failure}, it does not need to be the value of +call+: a private check
225
+ # method can end the service itself, which keeps +call+ a list of steps.
226
+ #
227
+ # It is not an exception: +rescue+ does not see it, and +ensure+ still runs.
228
+ # A failure ends only the service that raised it — a nested service called
229
+ # from +call+ hands its failure back as a response, as it always has.
230
+ #
231
+ # @param message [String, nil] custom error message (uses error type's default if nil)
232
+ # @param data [Object, nil] optional structured data to attach to the failure response
233
+ # @param type [Class] error class to instantiate (must inherit from ServiceError)
234
+ # @return [void] never returns
235
+ #
236
+ # @example Ending the service from a check
237
+ # def call
238
+ # account_open!
239
+ # success(account.withdraw!(amount))
240
+ # end
241
+ #
242
+ # private
243
+ #
244
+ # def account_open!
245
+ # return if account.open?
246
+ #
247
+ # failure!("Account is closed", type: Servus::Support::Errors::UnprocessableEntityError)
248
+ # end
249
+ #
250
+ # @note Leaving an ActiveRecord +transaction+ (or +with_lock+) block this way
251
+ # commits it, as any non-exception exit does. Run checks before writing, or
252
+ # raise to roll back.
253
+ # @see #failure
254
+ # @see #error!
255
+ def failure!(message = nil, data: nil, type: Servus::Support::Errors::ServiceError)
256
+ throw :service_failure, failure(message, data:, type:)
257
+ end
258
+
185
259
  # Logs an error and raises an exception, halting service execution.
186
260
  #
187
261
  # Use this method when you need to immediately halt execution with an exception
@@ -254,7 +328,7 @@ module Servus
254
328
 
255
329
  # Wrap execution in catch block to handle guard failures
256
330
  result = catch(:guard_failure) do
257
- benchmark(**args) { instance.send(:call) }
331
+ benchmark(**args) { catch(:service_failure) { instance.send(:call) } }
258
332
  end
259
333
 
260
334
  if result.is_a?(Servus::Support::Errors::GuardError)
data/lib/servus/config.rb CHANGED
@@ -76,6 +76,20 @@ module Servus
76
76
  # @return [Boolean] true to require payload schemas, false to allow schema-less events
77
77
  attr_accessor :require_event_payload_schema
78
78
 
79
+ # The logger Servus writes through.
80
+ #
81
+ # Left +nil+ (the default), Servus resolves one itself: `Rails.logger`
82
+ # when Rails is loaded, otherwise a `$stdout` logger. Read the resolved
83
+ # logger through {Servus.logger}.
84
+ #
85
+ # @return [::Logger, nil] the configured logger, or nil when none is set
86
+ #
87
+ # @example
88
+ # Servus.configure do |config|
89
+ # config.logger = SemanticLogger[Servus]
90
+ # end
91
+ attr_accessor :logger
92
+
79
93
  # The ordered list of routers that resolve invocations for events.
80
94
  #
81
95
  # The Bus iterates routers in order, collects invocations, deduplicates
@@ -181,6 +195,21 @@ module Servus
181
195
  @config ||= Config.new
182
196
  end
183
197
 
198
+ # The logger Servus writes through.
199
+ #
200
+ # Build a service's own logger from this one rather than replacing it, so
201
+ # the app's appenders and formatting are kept.
202
+ #
203
+ # @return [::Logger]
204
+ #
205
+ # @example A per-service logger that adds a tag (SemanticLogger)
206
+ # class Treasury::ApplicationService < Servus::Base
207
+ # self.logger = Servus.logger.tagged(engine: 'treasury')
208
+ # end
209
+ def self.logger
210
+ Servus::Support::Logger.logger
211
+ end
212
+
184
213
  # Yields the configuration for modification.
185
214
  #
186
215
  # @yieldparam config [Servus::Config] the configuration object to modify
@@ -70,21 +70,18 @@ module Servus
70
70
  # @see Servus::Base.call
71
71
  # @see #servus_job_class
72
72
  def call_async(**args)
73
- # Extract ActiveJob configuration options
74
- job_options = args.slice(:wait, :wait_until, :queue, :priority)
75
- job_options.merge!(args.delete(:job_options) || {}) # merge custom job options
76
- job_options.compact!
73
+ refuse_nameless_job!
77
74
 
78
- # Remove special keys that shouldn't be passed to the service
79
- args.except!(:wait, :wait_until, :queue, :priority, :job_options)
75
+ job_options = extract_job_options!(args)
80
76
 
81
77
  # The named job class identifies the service — only args are serialized.
82
78
  job = job_options.any? ? servus_job_class.set(**job_options) : servus_job_class
83
79
  job.perform_later(**args)
84
- rescue Servus::Support::Errors::ServiceError, Servus::Events::Errors::Error
80
+ rescue Servus::Support::Errors::ServiceError, Servus::Events::Errors::Error, Errors::AsyncError
85
81
  # With the :inline and :test adapters perform_later runs the service,
86
- # so Servus's own errors surface here. Wrapping them as an enqueue
87
- # failure would blame the wrong layer.
82
+ # so Servus's own errors surface here — as do this extension's own,
83
+ # like the name-conflict refusal above. Wrapping any of them as an
84
+ # enqueue failure would blame the wrong layer.
88
85
  raise
89
86
  rescue StandardError => e
90
87
  raise Errors::JobEnqueueError, "Failed to enqueue async job for #{self}: #{e.message}"
@@ -158,6 +155,23 @@ module Servus
158
155
 
159
156
  private
160
157
 
158
+ # Splits ActiveJob configuration out of the combined argument hash, mutating
159
+ # +args+ so only the service's own arguments remain.
160
+ #
161
+ # @param args [Hash] combined service arguments and job configuration options
162
+ # @return [Hash] the ActiveJob options (+wait+, +queue+, …) for +set+
163
+ # @api private
164
+ def extract_job_options!(args)
165
+ job_options = args.slice(:wait, :wait_until, :queue, :priority)
166
+ job_options.merge!(args.delete(:job_options) || {}) # merge custom job options
167
+ job_options.compact!
168
+
169
+ # Remove special keys that shouldn't be passed to the service
170
+ args.except!(:wait, :wait_until, :queue, :priority, :job_options)
171
+
172
+ job_options
173
+ end
174
+
161
175
  # Generates a named job subclass for this service and installs it as a sibling
162
176
  # constant in the service's parent namespace.
163
177
  #
@@ -166,6 +180,12 @@ module Servus
166
180
  # {#inherited}) means Rails' production eager-load defines every service's job
167
181
  # at boot, so worker processes can constantize the serialized job name.
168
182
  #
183
+ # An app that already owns that constant keeps it: +Foo+ alongside a
184
+ # hand-written +FooJob+ is ordinary Rails, and overwriting it replaced a real
185
+ # job class with this one silently. The generated class is still returned so
186
+ # the service keeps working, but it is unnamed — {#call_async} refuses it with
187
+ # {Errors::JobNameConflictError} rather than enqueue an unresolvable job.
188
+ #
169
189
  # @return [Class<Servus::Extensions::Async::Job>] the generated job class
170
190
  # @api private
171
191
  def build_servus_job_class
@@ -174,10 +194,77 @@ module Servus
174
194
  klass = Class.new(Servus::Extensions::Async::Job)
175
195
  klass.servus_service = self
176
196
 
177
- module_parent.const_set("#{name.demodulize}Job", klass)
197
+ publish_job_const(servus_job_const_name, klass)
178
198
 
179
199
  klass
180
200
  end
201
+
202
+ # @return [String] the sibling constant the service's job is published under
203
+ # @api private
204
+ def servus_job_const_name
205
+ "#{name.demodulize}Job"
206
+ end
207
+
208
+ # A job with no name lost its constant to the application (see
209
+ # {#publish_job_const}). ActiveJob serializes a job by its class name, so
210
+ # enqueueing it would succeed and then fail on the worker at
211
+ # deserialization — refuse at the call site instead.
212
+ #
213
+ # @raise [Errors::JobNameConflictError] when the service's job is unnamed
214
+ # @return [void]
215
+ # @api private
216
+ def refuse_nameless_job!
217
+ return unless servus_job_class.name.nil?
218
+
219
+ raise Errors::JobNameConflictError.for(self, qualified_const_name(servus_job_const_name))
220
+ end
221
+
222
+ # Installs the generated job as a sibling constant, unless the application
223
+ # owns that name — in which case its class is left alone and the skip is
224
+ # logged.
225
+ #
226
+ # @param const_name [String]
227
+ # @param klass [Class<Servus::Extensions::Async::Job>]
228
+ # @return [void]
229
+ # @api private
230
+ def publish_job_const(const_name, klass)
231
+ unless job_const_available?(const_name)
232
+ return Servus::Support::Logger.log_job_class_conflict(self, qualified_const_name(const_name))
233
+ end
234
+
235
+ # Reclaiming a stale generated job: drop it first so Ruby does not warn
236
+ # about an already-initialized constant on every reload.
237
+ module_parent.send(:remove_const, const_name) if module_parent.const_defined?(const_name, false)
238
+ module_parent.const_set(const_name, klass)
239
+ end
240
+
241
+ # Whether this service may publish its job class as +const_name+.
242
+ #
243
+ # Free names are available, and so is a name already holding a generated job —
244
+ # that is our own constant from a previous definition, which survives a
245
+ # development reload when the parent namespace is +Object+ and therefore
246
+ # unmanaged by Zeitwerk.
247
+ #
248
+ # A pending autoload is treated as the app's without resolving it: forcing the
249
+ # load here would run app code in the middle of defining a service.
250
+ #
251
+ # @param const_name [String]
252
+ # @return [Boolean]
253
+ # @api private
254
+ def job_const_available?(const_name)
255
+ return true unless module_parent.const_defined?(const_name, false)
256
+ return false if module_parent.autoload?(const_name)
257
+
258
+ existing = module_parent.const_get(const_name, false)
259
+ existing.is_a?(Class) && existing <= Servus::Extensions::Async::Job
260
+ end
261
+
262
+ # @param const_name [String]
263
+ # @return [String] the constant's fully qualified name
264
+ # @api private
265
+ def qualified_const_name(const_name)
266
+ module_parent.equal?(Object) ? const_name : "#{module_parent.name}::#{const_name}"
267
+ end
181
268
  end
182
269
  end
183
270
  end
@@ -22,6 +22,29 @@ module Servus
22
22
  # Services::SendEmail::Service.call_async(user_id: 123)
23
23
  # # => Servus::Extensions::Async::Errors::JobEnqueueError: Failed to enqueue async job
24
24
  class JobEnqueueError < AsyncError; end
25
+
26
+ # Raised when +call_async+ is invoked on a service whose generated job was
27
+ # never published because the application already owns the constant.
28
+ #
29
+ # The generated job class exists but has no name, and ActiveJob serializes a
30
+ # job by its class name — enqueueing it would succeed and then fail on the
31
+ # worker at deserialization, far from the call site. Refusing here keeps the
32
+ # failure where it was caused.
33
+ #
34
+ # @see Servus::Extensions::Async::Call#call_async
35
+ class JobNameConflictError < AsyncError
36
+ # @param service [Class] the service whose job constant is taken
37
+ # @param const_name [String] the constant the application owns
38
+ # @return [JobNameConflictError]
39
+ def self.for(service, const_name)
40
+ new(
41
+ "Cannot enqueue #{service.name}: its generated job was not published because " \
42
+ "#{const_name} is already defined by the application. An unnamed job would " \
43
+ 'enqueue but no worker could deserialize it. Rename the service or the ' \
44
+ "application's #{const_name} to stop the collision."
45
+ )
46
+ end
47
+ end
25
48
  end
26
49
  end
27
50
  end
@@ -12,6 +12,7 @@ module Servus
12
12
  module Async
13
13
  require 'active_support/core_ext/module/introspection' # Module#module_parent
14
14
  require 'active_support/core_ext/string/inflections' # String#demodulize
15
+ require 'active_support/core_ext/class/subclasses' # Class#descendants
15
16
 
16
17
  require 'servus/extensions/async/errors'
17
18
  require 'servus/extensions/async/job'
@@ -21,6 +22,33 @@ module Servus
21
22
  #
22
23
  # @api private
23
24
  module Ext; end
25
+
26
+ # Installs async support on {Servus::Base} and backfills job classes for
27
+ # services that were already defined.
28
+ #
29
+ # The backfill exists because {Call#inherited} — which publishes a service's
30
+ # job constant — only exists once this module is installed, and installation
31
+ # is deferred to +on_load(:active_job)+. Servus's own railtie force-loads
32
+ # +app/events/*_event.rb+, and every service named by an +enqueue+ there loads
33
+ # with it, typically before anything has touched +ActiveJob::Base+. Those
34
+ # services would otherwise never publish a job constant, and a worker — which
35
+ # resolves jobs by name and never calls {Call#call_async} — would fail
36
+ # deserialization with +ActiveJob::UnknownJobClassError+.
37
+ #
38
+ # Only services still reachable under their own name are backfilled. An
39
+ # anonymous service has no constant to publish, and a class whose name no
40
+ # longer resolves to it — replaced by a reload, or defined under a namespace
41
+ # that has since gone — has nothing to publish it under either.
42
+ #
43
+ # @return [void]
44
+ # @api private
45
+ def self.install!
46
+ Servus::Base.extend(Call)
47
+
48
+ Servus::Base.descendants.each do |service|
49
+ service.servus_job_class if service.name&.safe_constantize.equal?(service)
50
+ end
51
+ end
24
52
  end
25
53
  end
26
54
  end
@@ -14,7 +14,7 @@ module Servus
14
14
  initializer 'servus.job_async' do
15
15
  ActiveSupport.on_load(:active_job) do
16
16
  require 'servus/extensions/async/ext'
17
- Servus::Base.extend Servus::Extensions::Async::Call
17
+ Servus::Extensions::Async.install!
18
18
  end
19
19
  end
20
20
 
@@ -6,15 +6,16 @@ module Servus
6
6
  module Support
7
7
  # Logger class for logging service calls and results
8
8
  class Logger
9
- # Returns the logger instance depending on the environment
9
+ # The logger Servus writes through: the one set in {Servus::Config#logger},
10
+ # else `Rails.logger`, else a `$stdout` logger. Resolved on every read, so
11
+ # Servus follows Rails if the application swaps its logger after boot.
10
12
  #
11
- # @return [Logger] The logger instance
13
+ # Lines about a service go to that service's {Servus::Base.logger} instead.
14
+ #
15
+ # @return [::Logger] The logger instance
16
+ # @see Servus.logger
12
17
  def self.logger
13
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
14
- Rails.logger
15
- else
16
- @logger ||= ::Logger.new($stdout)
17
- end
18
+ Servus.config.logger || rails_logger || stdout_logger
18
19
  end
19
20
 
20
21
  # Logs a call to a service.
@@ -27,7 +28,7 @@ module Servus
27
28
  # @param args [Hash] The arguments passed to the service
28
29
  def self.log_call(service_class, args)
29
30
  rendered = log_parameters(args)
30
- logger.info("Calling #{service_class.name} with args: #{rendered.inspect}")
31
+ service_class.logger.info("Calling #{service_class.name} with args: #{rendered.inspect}")
31
32
  end
32
33
 
33
34
  # Logs a result from a service
@@ -48,7 +49,7 @@ module Servus
48
49
  # @param service_class [Class] The service class
49
50
  # @param duration [Float] The duration of the service call
50
51
  def self.log_success(service_class, duration)
51
- logger.info("#{service_class.name} succeeded in #{duration.round(3)}s")
52
+ service_class.logger.info("#{service_class.name} succeeded in #{duration.round(3)}s")
52
53
  end
53
54
 
54
55
  # Logs a failed result from a service
@@ -57,7 +58,7 @@ module Servus
57
58
  # @param error [Servus::Support::Errors::ServiceError] The error from the service
58
59
  # @param duration [Float] The duration of the service call
59
60
  def self.log_failure(service_class, error, duration)
60
- logger.warn("#{service_class.name} failed in #{duration.round(3)}s with error: #{error}")
61
+ service_class.logger.warn("#{service_class.name} failed in #{duration.round(3)}s with error: #{error}")
61
62
  end
62
63
 
63
64
  # Logs a guard failure from a service
@@ -65,7 +66,7 @@ module Servus
65
66
  # @param service_class [Class] The service class
66
67
  # @param error [Servus::Support::Errors::GuardError] The guard error
67
68
  def self.log_guard_failure(service_class, error)
68
- logger.warn("#{service_class.name} guard failed: #{error.message}")
69
+ service_class.logger.warn("#{service_class.name} guard failed: #{error.message}")
69
70
  end
70
71
 
71
72
  # Logs an event emission with correlation ID and duration.
@@ -83,7 +84,7 @@ module Servus
83
84
  # @param service_class [Class] The service class
84
85
  # @param error [Servus::Support::Errors::ValidationError] The validation error
85
86
  def self.log_validation_error(service_class, error)
86
- logger.error("#{service_class.name} validation error: #{error.message}")
87
+ service_class.logger.error("#{service_class.name} validation error: #{error.message}")
87
88
  end
88
89
 
89
90
  # Logs an uncaught exception from a service
@@ -91,7 +92,9 @@ module Servus
91
92
  # @param service_class [Class] The service class
92
93
  # @param exception [Exception] The uncaught exception
93
94
  def self.log_exception(service_class, exception)
94
- logger.error("#{service_class.name} uncaught exception: #{exception.class} - #{exception.message}")
95
+ service_class.logger.error(
96
+ "#{service_class.name} uncaught exception: #{exception.class} - #{exception.message}"
97
+ )
95
98
  end
96
99
 
97
100
  # Logs that a registered schema fragment was replaced with a different value.
@@ -104,6 +107,22 @@ module Servus
104
107
  logger.warn("Schema fragment #{key.inspect} was already registered with a different value; replacing it.")
105
108
  end
106
109
 
110
+ # Logs that a service could not publish its generated job class because the
111
+ # constant is already taken by the application.
112
+ #
113
+ # The application's class is left alone. The service still runs, but its
114
+ # generated job has no name, so +call_async+ on it cannot be serialized.
115
+ #
116
+ # @param service_class [Class<Servus::Base>] The service whose job was not published
117
+ # @param const_name [String] The constant the application already owns
118
+ def self.log_job_class_conflict(service_class, const_name)
119
+ service_class.logger.warn(
120
+ "#{service_class.name} did not publish its generated job as #{const_name} — that constant " \
121
+ 'is already defined by the application. The application class is unchanged; ' \
122
+ "#{service_class.name}.call_async cannot be enqueued until the service or the job is renamed."
123
+ )
124
+ end
125
+
107
126
  # Filters parameters for logging based on the configured filter list.
108
127
  #
109
128
  # @param params [Hash] The parameters to filter
@@ -115,6 +134,18 @@ module Servus
115
134
  Servus.config.parameter_filter.filter(params)
116
135
  end
117
136
  end
137
+
138
+ # @return [::Logger, nil] Rails' logger, when Rails is loaded and has one
139
+ def self.rails_logger
140
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
141
+ end
142
+ private_class_method :rails_logger
143
+
144
+ # @return [::Logger] the fallback logger, built once
145
+ def self.stdout_logger
146
+ @stdout_logger ||= ::Logger.new($stdout)
147
+ end
148
+ private_class_method :stdout_logger
118
149
  end
119
150
  end
120
151
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Servus
4
- VERSION = '1.0.1'
4
+ VERSION = '1.2.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: servus
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Scholl
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-28 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport