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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -10
- data/docs/core/1_overview.md +6 -2
- data/docs/core/3_service_objects.md +33 -0
- data/docs/features/1_schema_validation.md +43 -1
- data/docs/features/2_error_handling.md +9 -1
- data/docs/features/7_lazy_resolvers.md +238 -0
- data/docs/guides/2_migration_guide.md +51 -1
- data/docs/integration/2_testing.md +18 -1
- data/lib/servus/base.rb +21 -6
- data/lib/servus/event_handler.rb +27 -12
- data/lib/servus/extensions/lazily/call.rb +82 -0
- data/lib/servus/extensions/lazily/errors.rb +37 -0
- data/lib/servus/extensions/lazily/ext.rb +23 -0
- data/lib/servus/extensions/lazily/resolver.rb +32 -0
- data/lib/servus/railtie.rb +7 -1
- data/lib/servus/support/data_object.rb +80 -0
- data/lib/servus/support/errors.rb +291 -23
- data/lib/servus/support/response.rb +12 -1
- data/lib/servus/support/validator.rb +28 -14
- data/lib/servus/testing/example_builders.rb +22 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +1 -0
- metadata +12 -6
data/lib/servus/event_handler.rb
CHANGED
|
@@ -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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
|
254
|
-
service_kwargs =
|
|
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
|
data/lib/servus/railtie.rb
CHANGED
|
@@ -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
|