servus 0.2.0 → 0.3.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.
@@ -207,9 +207,9 @@ module Servus
207
207
  def collect_emitted_events
208
208
  events = Set.new
209
209
 
210
- ObjectSpace.each_object(Class)
211
- .select { |klass| klass < Servus::Base }
212
- .each do |service_class|
210
+ services = ObjectSpace.each_object(Class).select { |klass| klass < Servus::Base }
211
+
212
+ services.each do |service_class|
213
213
  service_class.event_emissions.each_value do |emissions|
214
214
  emissions.each { |emission| events << emission[:event_name] }
215
215
  end
@@ -226,9 +226,9 @@ module Servus
226
226
  def find_orphaned_handlers(emitted_events)
227
227
  orphaned = []
228
228
 
229
- ObjectSpace.each_object(Class)
230
- .select { |klass| klass < Servus::EventHandler && klass != Servus::EventHandler }
231
- .each do |handler_class|
229
+ handlers = ObjectSpace.each_object(Class).select { _1 < Servus::EventHandler && _1 != Servus::EventHandler }
230
+
231
+ handlers.each do |handler_class|
232
232
  next unless handler_class.event_name
233
233
  next if emitted_events.include?(handler_class.event_name)
234
234
 
@@ -245,15 +245,13 @@ module Servus
245
245
  # @return [Servus::Support::Response] the service result
246
246
  # @api private
247
247
  def invoke_service(invocation, payload)
248
- service_kwargs = invocation[:mapper].call(payload)
249
-
250
- async = invocation.dig(:options, :async) || false
251
- queue = invocation.dig(:options, :queue) || nil
248
+ use_async = invocation.dig(:options, :async) || false
252
249
 
253
- if async
254
- service_kwargs = service_kwargs.merge(queue: queue) if queue
250
+ if use_async
251
+ service_kwargs = prepare_call_sync_args(invocation, payload)
255
252
  invocation[:service_class].call_async(**service_kwargs)
256
253
  else
254
+ service_kwargs = invocation[:mapper].call(payload)
257
255
  invocation[:service_class].call(**service_kwargs)
258
256
  end
259
257
  end
@@ -270,6 +268,23 @@ module Servus
270
268
 
271
269
  true
272
270
  end
271
+
272
+ # Prepares the service arguments by merging event payload with job options.
273
+ #
274
+ # @param invocation [Hash] the invocation configuration
275
+ # @param payload [Hash] the event payload
276
+ # @return [Hash] combined service arguments
277
+ def prepare_call_sync_args(invocation, payload)
278
+ mapper = invocation[:mapper]
279
+ options = invocation[:options]
280
+
281
+ # Extract service arguments and merge with job options
282
+ job_opts = options.slice(:queue, :wait, :wait_until, :priority, :job_options).compact
283
+
284
+ mapper
285
+ .call(payload)
286
+ .merge(job_opts)
287
+ end
273
288
  end
274
289
  end
275
290
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Extensions
5
+ module Lazily
6
+ # Provides lazy record resolution for service inputs.
7
+ #
8
+ # This module extends {Servus::Base} with the {#lazily} class method, enabling
9
+ # services to accept either a record ID or an already-loaded record instance.
10
+ # Resolution happens lazily on first access and is memoized.
11
+ #
12
+ # @see Lazily#lazily
13
+ module Call
14
+ # Declares a lazy record resolver for a service input.
15
+ #
16
+ # Defines an accessor method that lazily resolves the input value to a record.
17
+ # If the value is already an instance of the target class, it is returned directly.
18
+ # If the value is an ID (or other lookup value), it is resolved via the target
19
+ # class's +.find+ or +.find_by!+ method. Arrays are resolved via +.where+.
20
+ #
21
+ # The resolved record is written back to the instance variable, so subsequent
22
+ # calls return the same object without re-querying.
23
+ #
24
+ # @param name [Symbol] the param/ivar name, also becomes the accessor method
25
+ # @param finds [Class] the model class to resolve against (e.g., +User+, +Account+)
26
+ # @param by [Symbol] the lookup column (default: +:id+). When +:id+, uses +.find+.
27
+ # Otherwise uses +.find_by!(column: value)+.
28
+ # @return [void]
29
+ #
30
+ # @example Basic usage with .find
31
+ # lazily :user, finds: User
32
+ # # user: 123 → User.find(123)
33
+ # # user: user_inst → returns user_inst directly
34
+ #
35
+ # @example Custom column lookup
36
+ # lazily :account, finds: Account, by: :uuid
37
+ # # account: "abc-def" → Account.find_by!(uuid: "abc-def")
38
+ #
39
+ # @example Array input
40
+ # lazily :users, finds: User
41
+ # # users: [1, 2, 3] → User.where(id: [1, 2, 3])
42
+ #
43
+ # @note Only available when ActiveRecord is loaded (via Railtie)
44
+ # @see Servus::Base.call
45
+ def lazily(name, finds:, by: :id)
46
+ (@lazy_resolvers ||= {})[name] = { klass: finds, by: by }
47
+ define_resolver_method(name, finds, by)
48
+ end
49
+
50
+ # Returns the hash of registered lazy resolvers for this service class.
51
+ #
52
+ # @return [Hash{Symbol => Hash}] resolver configurations keyed by name
53
+ # @api private
54
+ def lazy_resolvers
55
+ @lazy_resolvers || {}
56
+ end
57
+
58
+ private
59
+
60
+ # Defines a lazy accessor method on a prepended module.
61
+ #
62
+ # @param name [Symbol] the method/ivar name
63
+ # @param klass [Class] the target model class
64
+ # @param by [Symbol] the lookup column
65
+ # @api private
66
+ def define_resolver_method(name, klass, by)
67
+ mod = (@_resolver_module ||= Module.new)
68
+ prepend(mod) unless ancestors.include?(mod)
69
+
70
+ mod.define_method(name) do
71
+ @_lazily_resolved ||= {}
72
+ return instance_variable_get(:"@#{name}") if @_lazily_resolved[name]
73
+
74
+ resolved = Resolver.call(instance_variable_get(:"@#{name}"), klass: klass, by: by, name: name)
75
+ @_lazily_resolved[name] = true
76
+ instance_variable_set(:"@#{name}", resolved)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Extensions
5
+ module Lazily
6
+ # Error classes for lazy record resolution.
7
+ #
8
+ # These errors are raised when lazy resolution fails, such as when
9
+ # a required record reference is nil.
10
+ module Errors
11
+ # Base error class for all lazily extension errors.
12
+ #
13
+ # All lazy resolution errors inherit from this class for easy rescue handling.
14
+ class LazilyError < StandardError; end
15
+
16
+ # Raised when a lazily-resolved record reference is nil.
17
+ #
18
+ # This occurs when a service declares +lazily :user, finds: User+ but
19
+ # receives +user: nil+. A nil reference is always a bug at the call site.
20
+ #
21
+ # @example
22
+ # class MyService < Servus::Base
23
+ # lazily :user, finds: User
24
+ #
25
+ # def initialize(user:)
26
+ # @user = user
27
+ # end
28
+ #
29
+ # def call
30
+ # user # => raises NotFoundError if @user is nil
31
+ # end
32
+ # end
33
+ class NotFoundError < LazilyError; end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Extensions
5
+ # Lazy record resolution extensions for Servus services.
6
+ #
7
+ # This module provides the infrastructure for lazily resolving record
8
+ # references (IDs or instances) in service inputs. When loaded, it extends
9
+ # {Servus::Base} with the {Call#lazily} class method.
10
+ #
11
+ # @see Servus::Extensions::Lazily::Call
12
+ module Lazily
13
+ require 'servus/extensions/lazily/errors'
14
+ require 'servus/extensions/lazily/resolver'
15
+ require 'servus/extensions/lazily/call'
16
+
17
+ # Extension module for lazily functionality.
18
+ #
19
+ # @api private
20
+ module Ext; end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Extensions
5
+ module Lazily
6
+ # Performs the actual record resolution for a lazily-declared input.
7
+ #
8
+ # Handles the decision logic: is the raw value already an instance,
9
+ # nil, an array, or a lookup value? Delegates to the appropriate
10
+ # finder method on the target class.
11
+ #
12
+ # @api private
13
+ class Resolver
14
+ # Resolves a raw value to a record (or collection of records).
15
+ #
16
+ # @param raw [Object] the raw input value (ID, instance, Array, or nil)
17
+ # @param klass [Class] the target model class
18
+ # @param by [Symbol] the lookup column
19
+ # @param name [Symbol] the param name (for error messages)
20
+ # @return [Object] the resolved record or collection
21
+ # @raise [Errors::NotFoundError] if raw is nil
22
+ def self.call(raw, klass:, by:, name:)
23
+ return raw if raw.is_a?(klass)
24
+ raise Errors::NotFoundError, "Couldn't find #{klass} (#{name} was nil)" if raw.nil?
25
+ return klass.where(by => raw) if raw.is_a?(Array)
26
+
27
+ by == :id ? klass.find(raw) : klass.find_by!(by => raw)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -14,11 +14,17 @@ 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
- # Extend the base service with the async call method
18
17
  Servus::Base.extend Servus::Extensions::Async::Call
19
18
  end
20
19
  end
21
20
 
21
+ initializer 'servus.lazily' do
22
+ ActiveSupport.on_load(:active_record) do
23
+ require 'servus/extensions/lazily/ext'
24
+ Servus::Base.extend Servus::Extensions::Lazily::Call
25
+ end
26
+ end
27
+
22
28
  # Load guards and event handlers, clear caches on reload
23
29
  config.to_prepare do
24
30
  # Load custom guards from guards_dir
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module Servus
6
+ module Support
7
+ # A read-only wrapper around Hash data that provides accessor-style access.
8
+ #
9
+ # When service results contain Hash data, +DataObject+ wraps it so keys can be
10
+ # accessed as methods in addition to the standard bracket syntax. Nested Hashes
11
+ # are recursively wrapped, enabling chained access like +data.user.address.city+.
12
+ #
13
+ # Non-Hash values (nil, String, Integer, Array, model instances) pass through
14
+ # unwrapped. This means +data.user+ returns the original object when +user+ is
15
+ # an ActiveRecord model, allowing natural method chaining (+data.user.email+).
16
+ #
17
+ # +DataObject+ inherits from +SimpleDelegator+, so all standard Hash methods
18
+ # (+[]+, +keys+, +each+, +as_json+, +==+, etc.) are delegated transparently.
19
+ #
20
+ # @example Accessor-style access
21
+ # data = DataObject.wrap({ user: { email: "alice@example.com" } })
22
+ # data.user.email # => "alice@example.com"
23
+ # data[:user] # => { email: "alice@example.com" } (plain Hash)
24
+ #
25
+ # @example Mixed values
26
+ # data = DataObject.wrap({ user: user_model, metadata: { source: "api" } })
27
+ # data.user.email # => delegates to model's #email method
28
+ # data.metadata.source # => "api" (wrapped Hash accessor)
29
+ #
30
+ # @see Servus::Support::Response
31
+ class DataObject < SimpleDelegator
32
+ # Wraps a value in a DataObject if it is a Hash.
33
+ #
34
+ # Non-Hash values are returned unchanged. This is the preferred way to
35
+ # create DataObject instances, as it handles nil and non-Hash types safely.
36
+ #
37
+ # @param data [Object] the value to potentially wrap
38
+ # @return [DataObject] if data is a Hash
39
+ # @return [Array] with Hash elements wrapped if data is an Array
40
+ # @return [Object] the original value otherwise
41
+ def self.wrap(data)
42
+ case data
43
+ when Hash then new(data)
44
+ when Array then data.map { |item| wrap(item) }
45
+ else data
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # Provides accessor-style access to Hash keys.
52
+ #
53
+ # Only zero-argument, no-block calls trigger key lookup. Methods with
54
+ # arguments (e.g., +fetch+, +dig+) delegate to Hash normally.
55
+ # When the value is a Hash, it is recursively wrapped in a DataObject.
56
+ #
57
+ # @param method_name [Symbol] the method name to look up as a key
58
+ # @return [Object] the value for the key, wrapped if it is a Hash
59
+ # @raise [NoMethodError] if the key does not exist
60
+ def method_missing(method_name, *args, &block)
61
+ return super if args.any? || block
62
+
63
+ hash = __getobj__
64
+ if hash.key?(method_name.to_sym)
65
+ self.class.wrap(hash[method_name.to_sym])
66
+ elsif hash.key?(method_name.to_s)
67
+ self.class.wrap(hash[method_name.to_s])
68
+ else
69
+ super
70
+ end
71
+ end
72
+
73
+ # @api private
74
+ def respond_to_missing?(method_name, include_private = false)
75
+ hash = __getobj__
76
+ hash.key?(method_name.to_sym) || hash.key?(method_name.to_s) || super
77
+ end
78
+ end
79
+ end
80
+ end