interactor_support 1.0.1 → 1.0.3
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/.prettierignore +1 -0
- data/.yardopts +19 -0
- data/CHANGELOG.md +10 -0
- data/README.md +258 -5
- data/lib/interactor_support/actions.rb +31 -0
- data/lib/interactor_support/concerns/findable.rb +64 -20
- data/lib/interactor_support/concerns/skippable.rb +42 -0
- data/lib/interactor_support/concerns/transactionable.rb +37 -0
- data/lib/interactor_support/concerns/transformable.rb +78 -15
- data/lib/interactor_support/concerns/updatable.rb +43 -1
- data/lib/interactor_support/configuration.rb +32 -9
- data/lib/interactor_support/core.rb +19 -1
- data/lib/interactor_support/request_object.rb +131 -19
- data/lib/interactor_support/validations.rb +89 -9
- data/lib/interactor_support/version.rb +1 -1
- data/lib/interactor_support.rb +44 -0
- metadata +4 -2
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
2
|
module Concerns
|
|
3
|
+
##
|
|
4
|
+
# Adds helpers for assigning and transforming values in interactor context.
|
|
5
|
+
#
|
|
6
|
+
# The `context_variable` method sets static or dynamic values before the interactor runs.
|
|
7
|
+
# The `transform` method allows chaining transformations (methods or lambdas) on context values.
|
|
8
|
+
#
|
|
9
|
+
# @example Assign context variables before the interactor runs
|
|
10
|
+
# context_variable user: -> { User.find(user_id) }, numbers: [1, 2, 3]
|
|
11
|
+
#
|
|
12
|
+
# @example Normalize email and name before using them
|
|
13
|
+
# transform :email, :name, with: [:strip, :downcase]
|
|
14
|
+
#
|
|
15
|
+
# @example Apply a lambda to clean up input
|
|
16
|
+
# transform :name, with: ->(value) { value.gsub(/\s+/, ' ').strip }
|
|
17
|
+
#
|
|
18
|
+
# @example Mixing symbols and lambdas
|
|
19
|
+
# transform :email, with: [:strip, :downcase, -> { email.gsub(/\s+/, '') }]
|
|
20
|
+
#
|
|
21
|
+
# @see InteractorSupport::Actions
|
|
3
22
|
module Transformable
|
|
4
23
|
extend ActiveSupport::Concern
|
|
5
24
|
include InteractorSupport::Core
|
|
6
25
|
|
|
7
26
|
included do
|
|
8
27
|
class << self
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
28
|
+
# Assigns one or more values to the context before the interactor runs.
|
|
29
|
+
#
|
|
30
|
+
# Values can be static or lazily evaluated with a lambda/proc using `instance_exec`,
|
|
31
|
+
# which provides access to the context and interactor instance.
|
|
32
|
+
#
|
|
33
|
+
# @param key_values [Hash{Symbol => Object, Proc}] a mapping of context keys to values or Procs
|
|
34
|
+
#
|
|
35
|
+
# @example Static and dynamic values
|
|
36
|
+
# context_variable first_post: Post.first
|
|
37
|
+
# context_variable user: -> { User.find(user_id) }
|
|
38
|
+
# context_variable numbers: [1, 2, 3]
|
|
13
39
|
def context_variable(key_values)
|
|
14
40
|
before do
|
|
15
41
|
key_values.each do |key, value|
|
|
@@ -22,12 +48,40 @@ module InteractorSupport
|
|
|
22
48
|
end
|
|
23
49
|
end
|
|
24
50
|
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
51
|
+
# Transforms one or more context values using a method, a proc, or a sequence of methods.
|
|
52
|
+
#
|
|
53
|
+
# This allows simple transformations like `:strip` or `:downcase`, or more complex lambdas.
|
|
54
|
+
# You can also chain transformations by passing an array of method names.
|
|
55
|
+
#
|
|
56
|
+
# If a transformation fails, the context fails with an error message.
|
|
57
|
+
#
|
|
58
|
+
# @param keys [Array<Symbol>] one or more context keys to transform
|
|
59
|
+
# @param with [Symbol, Array<Symbol>, Proc] a single method name, an array of method names, or a proc
|
|
60
|
+
#
|
|
61
|
+
# @raise [ArgumentError] if no keys are given, or if an invalid `with:` value is passed
|
|
62
|
+
#
|
|
63
|
+
# @example Single method
|
|
64
|
+
# transform :email, with: :strip
|
|
65
|
+
#
|
|
66
|
+
# @example Method chain
|
|
67
|
+
# transform :email, with: [:strip, :downcase]
|
|
68
|
+
#
|
|
69
|
+
# @example Lambda
|
|
70
|
+
# transform :url, with: ->(value) { value.downcase.strip }
|
|
71
|
+
#
|
|
72
|
+
# @example Multiple keys
|
|
73
|
+
# transform :email, :name, with: [:downcase, :strip]
|
|
74
|
+
#
|
|
75
|
+
# @example Normalize user input
|
|
76
|
+
# transform :email, :name, with: [
|
|
77
|
+
# :strip,
|
|
78
|
+
# :downcase,
|
|
79
|
+
# ->(value) { value.gsub(/\s+/, ' ') }, # collapse duplicate spaces
|
|
80
|
+
# ]
|
|
81
|
+
#
|
|
82
|
+
# # Result:
|
|
83
|
+
# # context[:email] = "someone@example.com"
|
|
84
|
+
# # context[:name] = "john doe"
|
|
31
85
|
def transform(*keys, with: [])
|
|
32
86
|
before do
|
|
33
87
|
if keys.empty?
|
|
@@ -42,11 +96,20 @@ module InteractorSupport
|
|
|
42
96
|
context.fail!(errors: ["#{key} failed to transform: #{e.message}"])
|
|
43
97
|
end
|
|
44
98
|
elsif with.is_a?(Array)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
99
|
+
with.each do |method|
|
|
100
|
+
if method.is_a?(Proc)
|
|
101
|
+
begin
|
|
102
|
+
context[key] = context.instance_exec(&method)
|
|
103
|
+
rescue => e
|
|
104
|
+
context.fail!(errors: ["#{key} failed to transform: #{e.message}"])
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
context.fail!(
|
|
108
|
+
errors: ["#{key} does not respond to all transforms"],
|
|
109
|
+
) unless context[key].respond_to?(method)
|
|
110
|
+
|
|
111
|
+
context[key] = context[key].send(method)
|
|
112
|
+
end
|
|
50
113
|
end
|
|
51
114
|
elsif with.is_a?(Symbol) && context[key].respond_to?(with)
|
|
52
115
|
context[key] = context[key].send(with)
|
|
@@ -1,11 +1,54 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
2
|
module Concerns
|
|
3
|
+
##
|
|
4
|
+
# Adds an `update` DSL method for updating a context-loaded model with attributes.
|
|
5
|
+
#
|
|
6
|
+
# This concern allows flexible updates using data from the interactor's context.
|
|
7
|
+
# It supports direct mapping from context keys, nested attribute extraction from parent objects,
|
|
8
|
+
# lambdas for dynamic evaluation, or passing a symbol pointing to an entire context object.
|
|
9
|
+
#
|
|
10
|
+
# This is useful for updating records cleanly and consistently in declarative steps.
|
|
11
|
+
#
|
|
12
|
+
# @example Update a user using context values
|
|
13
|
+
# update :user, attributes: { name: :new_name, email: :new_email }
|
|
14
|
+
#
|
|
15
|
+
# @example Extract nested fields from a context object
|
|
16
|
+
# update :user, attributes: { form_data: [:name, :email] }
|
|
17
|
+
#
|
|
18
|
+
# @example Use a lambda for computed value
|
|
19
|
+
# update :post, attributes: { published_at: -> { Time.current } }
|
|
20
|
+
#
|
|
21
|
+
# @see InteractorSupport::Actions
|
|
3
22
|
module Updatable
|
|
4
23
|
extend ActiveSupport::Concern
|
|
5
24
|
include InteractorSupport::Core
|
|
6
25
|
|
|
7
26
|
included do
|
|
8
27
|
class << self
|
|
28
|
+
# Updates a model using values from the context before the interactor runs.
|
|
29
|
+
#
|
|
30
|
+
# Supports flexible ways of specifying attributes:
|
|
31
|
+
# - A hash mapping attribute names to context keys, nested keys, or lambdas
|
|
32
|
+
# - A symbol pointing to a hash in context
|
|
33
|
+
#
|
|
34
|
+
# If the record or required data is missing, the context fails with an error.
|
|
35
|
+
#
|
|
36
|
+
# @param model [Symbol] the key in the context holding the record to update
|
|
37
|
+
# @param attributes [Hash, Symbol] a hash mapping attributes to context keys/lambdas, or a symbol pointing to a context hash
|
|
38
|
+
# @param context_key [Symbol, nil] key to assign the updated record to in context (defaults to `model`)
|
|
39
|
+
#
|
|
40
|
+
# @example Basic attribute update using context keys
|
|
41
|
+
# update :user, attributes: { name: :new_name, email: :new_email }
|
|
42
|
+
#
|
|
43
|
+
# @example Use a lambda for dynamic value
|
|
44
|
+
# update :post, attributes: { published_at: -> { Time.current } }
|
|
45
|
+
#
|
|
46
|
+
# @example Nested context value lookup from a parent object
|
|
47
|
+
# # Assuming context[:form_data] = OpenStruct.new(name: "Hi", email: "hi@example.com")
|
|
48
|
+
# update :user, attributes: { form_data: [:name, :email] }
|
|
49
|
+
#
|
|
50
|
+
# @example Using a symbol to fetch all attributes from another context object
|
|
51
|
+
# update :order, attributes: :order_attributes
|
|
9
52
|
def update(model, attributes: {}, context_key: nil)
|
|
10
53
|
context_key ||= model
|
|
11
54
|
|
|
@@ -46,7 +89,6 @@ module InteractorSupport
|
|
|
46
89
|
|
|
47
90
|
record.update!(update_data)
|
|
48
91
|
|
|
49
|
-
# Assign the updated record to context
|
|
50
92
|
context[context_key] = record
|
|
51
93
|
end
|
|
52
94
|
end
|
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
|
+
##
|
|
3
|
+
# Global configuration for InteractorSupport.
|
|
4
|
+
#
|
|
5
|
+
# This allows customization of how request objects behave when used in interactors.
|
|
6
|
+
#
|
|
7
|
+
# @example Set custom behavior
|
|
8
|
+
# InteractorSupport.configuration.request_object_behavior = :returns_self
|
|
9
|
+
# InteractorSupport.configuration.request_object_key_type = :struct
|
|
10
|
+
#
|
|
11
|
+
# @see InteractorSupport.configuration
|
|
2
12
|
class Configuration
|
|
3
|
-
|
|
13
|
+
##
|
|
14
|
+
# Defines how request objects behave when called.
|
|
15
|
+
#
|
|
16
|
+
# - `:returns_context` — The request object returns an Interactor-style context.
|
|
17
|
+
# - `:returns_self` — The request object returns itself, allowing method chaining.
|
|
18
|
+
#
|
|
19
|
+
# @return [:returns_context, :returns_self]
|
|
20
|
+
attr_accessor :request_object_behavior
|
|
4
21
|
|
|
22
|
+
##
|
|
23
|
+
# Defines the key type used in request object context when `:returns_context` is active.
|
|
24
|
+
#
|
|
25
|
+
# - `:string` — Keys are string-based (`"name"`)
|
|
26
|
+
# - `:symbol` — Keys are symbol-based (`:name`)
|
|
27
|
+
# - `:struct` — Keys are accessed via struct-style method calls (`name`)
|
|
28
|
+
#
|
|
29
|
+
# @return [:string, :symbol, :struct]
|
|
30
|
+
attr_accessor :request_object_key_type
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# Initializes the configuration with default values:
|
|
34
|
+
# - `request_object_behavior` defaults to `:returns_context`
|
|
35
|
+
# - `request_object_key_type` defaults to `:symbol`
|
|
5
36
|
def initialize
|
|
6
|
-
# Default configuration values.
|
|
7
|
-
# :returns_context - request objects return a context object.
|
|
8
|
-
# :returns_self - request objects return self.
|
|
9
37
|
@request_object_behavior = :returns_context
|
|
10
|
-
|
|
11
|
-
# Default configuration values, only applies when request_object_behavior is :returns_context.
|
|
12
|
-
# :string - request object keys are strings.
|
|
13
|
-
# :symbol - request object keys are symbols.
|
|
14
|
-
# :struct - request object keys are struct objects.
|
|
15
38
|
@request_object_key_type = :symbol
|
|
16
39
|
end
|
|
17
40
|
end
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
|
+
##
|
|
3
|
+
# Core behavior that ensures the `Interactor` module is included
|
|
4
|
+
# when any InteractorSupport concern is mixed in.
|
|
5
|
+
#
|
|
6
|
+
# This module is automatically included by all `InteractorSupport::Concerns`,
|
|
7
|
+
# so you generally do not need to include it manually.
|
|
8
|
+
#
|
|
9
|
+
# @example Included implicitly
|
|
10
|
+
# class MyInteractor
|
|
11
|
+
# include InteractorSupport::Concerns::Findable
|
|
12
|
+
# # => Interactor is automatically included
|
|
13
|
+
# end
|
|
2
14
|
module Core
|
|
3
15
|
class << self
|
|
16
|
+
##
|
|
17
|
+
# Ensures the `Interactor` module is included in the base class.
|
|
18
|
+
#
|
|
19
|
+
# This hook runs when `Core` is included by a concern and conditionally
|
|
20
|
+
# includes `Interactor` if not already present.
|
|
21
|
+
#
|
|
22
|
+
# @param base [Class] the class or module including this concern
|
|
4
23
|
def included(base)
|
|
5
|
-
# Only include Interactor if it isn’t already present.
|
|
6
24
|
base.include(Interactor) unless base.included_modules.include?(Interactor)
|
|
7
25
|
end
|
|
8
26
|
end
|
|
@@ -1,18 +1,75 @@
|
|
|
1
|
-
# app/concerns/interactor_support/request_object.rb
|
|
2
1
|
module InteractorSupport
|
|
2
|
+
##
|
|
3
|
+
# A base module for building validated, transformable, and optionally nested request objects.
|
|
4
|
+
#
|
|
5
|
+
# It builds on top of `ActiveModel::Model`, adds coercion, default values, attribute transforms,
|
|
6
|
+
# key rewriting, and automatic context conversion (via `#to_context`). It integrates tightly with
|
|
7
|
+
# `InteractorSupport::Configuration` to control return behavior and key formatting.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# class CreateUserRequest
|
|
11
|
+
# include InteractorSupport::RequestObject
|
|
12
|
+
#
|
|
13
|
+
# attribute :name, transform: [:strip, :downcase]
|
|
14
|
+
# attribute :email
|
|
15
|
+
# attribute :metadata, default: {}
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# CreateUserRequest.new(name: " JOHN ", email: "hi@example.com")
|
|
19
|
+
# # => { name: "john", email: "hi@example.com", metadata: {} }
|
|
20
|
+
#
|
|
21
|
+
# @example Key rewriting
|
|
22
|
+
# class UploadRequest
|
|
23
|
+
# include InteractorSupport::RequestObject
|
|
24
|
+
#
|
|
25
|
+
# attribute :image, rewrite: :image_url
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# UploadRequest.new(image: "url").image_url # => "url"
|
|
29
|
+
#
|
|
30
|
+
# @see InteractorSupport::Configuration
|
|
3
31
|
module RequestObject
|
|
4
32
|
extend ActiveSupport::Concern
|
|
33
|
+
SUPPORTED_ACTIVEMODEL_TYPES = ActiveModel::Type.registry.send(:registrations).keys.map { |type| ":#{type}" }
|
|
34
|
+
SUPPORTED_PRIMITIVES = ['AnyClass', 'Symbol', 'Hash', 'Array']
|
|
35
|
+
SUPPORTED_TYPES = SUPPORTED_PRIMITIVES + SUPPORTED_ACTIVEMODEL_TYPES
|
|
36
|
+
|
|
37
|
+
class TypeError < StandardError
|
|
38
|
+
end
|
|
5
39
|
|
|
6
40
|
included do
|
|
7
41
|
include ActiveModel::Model
|
|
8
42
|
include ActiveModel::Attributes
|
|
9
43
|
include ActiveModel::Validations::Callbacks
|
|
10
44
|
|
|
45
|
+
##
|
|
46
|
+
# Initializes the request object and raises if invalid.
|
|
47
|
+
#
|
|
48
|
+
# Rewritten keys are converted before passing to ActiveModel.
|
|
49
|
+
#
|
|
50
|
+
# @param attributes [Hash] the input attributes
|
|
51
|
+
# @raise [ActiveModel::ValidationError] if the object is invalid
|
|
11
52
|
def initialize(attributes = {})
|
|
53
|
+
attributes = attributes.dup
|
|
54
|
+
self.class.rewritten_attributes.each do |external, internal|
|
|
55
|
+
if attributes.key?(external)
|
|
56
|
+
attributes[internal] = attributes.delete(external)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
12
60
|
super(attributes)
|
|
13
61
|
raise ActiveModel::ValidationError, self unless valid?
|
|
14
62
|
end
|
|
15
63
|
|
|
64
|
+
##
|
|
65
|
+
# Converts the request object into a format suitable for interactor context.
|
|
66
|
+
#
|
|
67
|
+
# - If `key_type` is `:symbol` or `:string`, returns a Hash.
|
|
68
|
+
# - If `key_type` is `:struct`, returns a Struct instance.
|
|
69
|
+
#
|
|
70
|
+
# Nested request objects will also be converted recursively.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash, Struct]
|
|
16
73
|
def to_context
|
|
17
74
|
key_type = InteractorSupport.configuration.request_object_key_type
|
|
18
75
|
attrs = attributes.each_with_object({}) do |(name, value), hash|
|
|
@@ -31,26 +88,42 @@ module InteractorSupport
|
|
|
31
88
|
end
|
|
32
89
|
|
|
33
90
|
class << self
|
|
91
|
+
##
|
|
92
|
+
# Custom constructor that optionally returns the context instead of the object itself.
|
|
93
|
+
#
|
|
94
|
+
# Behavior is configured via `InteractorSupport.configuration.request_object_behavior`.
|
|
95
|
+
#
|
|
96
|
+
# @param args [Array] positional args
|
|
97
|
+
# @param kwargs [Hash] keyword args
|
|
98
|
+
# @return [RequestObject, Hash, Struct]
|
|
34
99
|
def new(*args, **kwargs)
|
|
35
100
|
return super(*args, **kwargs) if InteractorSupport.configuration.request_object_behavior == :returns_self
|
|
36
101
|
|
|
37
102
|
super(*args, **kwargs).to_context
|
|
38
103
|
end
|
|
39
104
|
|
|
40
|
-
|
|
105
|
+
##
|
|
106
|
+
# Defines one or more attributes with optional coercion, default values, transformation,
|
|
107
|
+
# and an optional `rewrite:` key to rename the underlying attribute.
|
|
108
|
+
#
|
|
109
|
+
# @param names [Array<Symbol>] the attribute names
|
|
110
|
+
# @param type [Class, nil] optional class to coerce the value to (often another request object)
|
|
111
|
+
# @param array [Boolean] whether to treat the input as an array of typed objects
|
|
112
|
+
# @param default [Object] default value if not provided
|
|
113
|
+
# @param transform [Symbol, Array<Symbol>] method(s) to apply to the value
|
|
114
|
+
# @param rewrite [Symbol, nil] optional internal name to rewrite this attribute to
|
|
41
115
|
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
# - array: when true, expects an array; each element is cast.
|
|
45
|
-
# - default: default value for the attribute.
|
|
46
|
-
# - transform: a symbol or an array of symbols that will be applied (if the value responds to them).
|
|
47
|
-
def attribute(*names, type: nil, array: false, default: nil, transform: nil)
|
|
116
|
+
# @raise [ArgumentError] if a transform method is not found
|
|
117
|
+
def attribute(*names, type: nil, array: false, default: nil, transform: nil, rewrite: nil)
|
|
48
118
|
names.each do |name|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
119
|
+
attr_name = rewrite || name
|
|
120
|
+
rewritten_attributes[name.to_sym] = attr_name if rewrite
|
|
121
|
+
transform_options[attr_name.to_sym] = transform if transform.present?
|
|
122
|
+
|
|
123
|
+
super(attr_name, default: default)
|
|
124
|
+
original_writer = instance_method("#{attr_name}=")
|
|
125
|
+
|
|
126
|
+
define_method("#{attr_name}=") do |value|
|
|
54
127
|
if transform
|
|
55
128
|
Array(transform).each do |method|
|
|
56
129
|
if value.respond_to?(method)
|
|
@@ -62,25 +135,64 @@ module InteractorSupport
|
|
|
62
135
|
end
|
|
63
136
|
end
|
|
64
137
|
end
|
|
65
|
-
|
|
138
|
+
|
|
139
|
+
# If a `type` is specified, we attempt to cast the `value` to that type
|
|
66
140
|
if type
|
|
67
|
-
value =
|
|
68
|
-
Array(value).map { |v| v.is_a?(type) ? v : type.new(v) }
|
|
69
|
-
else
|
|
70
|
-
value.is_a?(type) ? value : type.new(value)
|
|
71
|
-
end
|
|
141
|
+
value = array ? Array(value).map { |v| cast_value(v, type) } : cast_value(value, type)
|
|
72
142
|
end
|
|
143
|
+
|
|
73
144
|
original_writer.bind(self).call(value)
|
|
74
145
|
end
|
|
75
146
|
end
|
|
76
147
|
end
|
|
77
148
|
|
|
149
|
+
##
|
|
150
|
+
# Internal map of external attribute names to internal rewritten names.
|
|
151
|
+
#
|
|
152
|
+
# @return [Hash{Symbol => Symbol}]
|
|
153
|
+
def rewritten_attributes
|
|
154
|
+
@_rewritten_attributes ||= {}
|
|
155
|
+
end
|
|
156
|
+
|
|
78
157
|
private
|
|
79
158
|
|
|
159
|
+
##
|
|
160
|
+
# Internal storage for transform options per attribute.
|
|
161
|
+
#
|
|
162
|
+
# @return [Hash{Symbol => Symbol, Array<Symbol>}]
|
|
80
163
|
def transform_options
|
|
81
164
|
@_transform_options ||= {}
|
|
82
165
|
end
|
|
83
166
|
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def cast_value(value, type)
|
|
171
|
+
return typecast(value, type) if type.is_a?(Symbol)
|
|
172
|
+
return value if value.is_a?(type)
|
|
173
|
+
return type.new(value) if type <= InteractorSupport::RequestObject
|
|
174
|
+
|
|
175
|
+
typecast(value, type)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def typecast(value, type)
|
|
179
|
+
if type.is_a?(Symbol)
|
|
180
|
+
ActiveModel::Type.lookup(type).cast(value)
|
|
181
|
+
elsif type == Symbol
|
|
182
|
+
value.to_sym
|
|
183
|
+
elsif type == Array
|
|
184
|
+
value.to_a
|
|
185
|
+
elsif type == Hash
|
|
186
|
+
value.to_h
|
|
187
|
+
else
|
|
188
|
+
raise TypeError
|
|
189
|
+
end
|
|
190
|
+
rescue ArgumentError
|
|
191
|
+
message = ":#{type} is not a supported type. Supported types are: #{SUPPORTED_TYPES.join(", ")}"
|
|
192
|
+
raise TypeError, message
|
|
193
|
+
rescue
|
|
194
|
+
raise TypeError, "Cannot cast #{value.inspect} to #{type.name}"
|
|
195
|
+
end
|
|
84
196
|
end
|
|
85
197
|
end
|
|
86
198
|
end
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
require 'active_model'
|
|
2
2
|
|
|
3
3
|
module InteractorSupport
|
|
4
|
+
##
|
|
5
|
+
# Provides context-aware validation DSL for interactors.
|
|
6
|
+
#
|
|
7
|
+
# This module adds `ActiveModel::Validations` and wraps it with methods like
|
|
8
|
+
# `required`, `optional`, `validates_before`, and `validates_after`, allowing
|
|
9
|
+
# declarative validation of interactor context values.
|
|
10
|
+
#
|
|
11
|
+
# Validations are executed automatically before (or after) the interactor runs.
|
|
12
|
+
#
|
|
13
|
+
# @example Required attributes with ActiveModel rules
|
|
14
|
+
# required :email, :name
|
|
15
|
+
# required age: { numericality: { greater_than: 18 } }
|
|
16
|
+
#
|
|
17
|
+
# @example Optional attributes with presence/format validations
|
|
18
|
+
# optional bio: { length: { maximum: 500 } }
|
|
19
|
+
#
|
|
20
|
+
# @example Type and inclusion validation before execution
|
|
21
|
+
# validates_before :role, type: String, inclusion: { in: %w[admin user guest] }
|
|
22
|
+
#
|
|
23
|
+
# @example Persistence validation after execution
|
|
24
|
+
# validates_after :user, persisted: true
|
|
25
|
+
#
|
|
26
|
+
# @see ActiveModel::Validations
|
|
4
27
|
module Validations
|
|
5
28
|
extend ActiveSupport::Concern
|
|
6
29
|
include InteractorSupport::Core
|
|
@@ -11,14 +34,35 @@ module InteractorSupport
|
|
|
11
34
|
end
|
|
12
35
|
|
|
13
36
|
class_methods do
|
|
37
|
+
##
|
|
38
|
+
# Declares one or more attributes as required.
|
|
39
|
+
#
|
|
40
|
+
# Values must be present in the context. You can also pass validation options
|
|
41
|
+
# as a hash, which will be forwarded to ActiveModel's `validates`.
|
|
42
|
+
#
|
|
43
|
+
# @param keys [Array<Symbol, Hash>] attribute names or hash of attributes with validation options
|
|
14
44
|
def required(*keys)
|
|
15
45
|
apply_validations(keys, required: true)
|
|
16
46
|
end
|
|
17
47
|
|
|
48
|
+
##
|
|
49
|
+
# Declares one or more attributes as optional.
|
|
50
|
+
#
|
|
51
|
+
# Optional values can be nil, but still support validation rules.
|
|
52
|
+
#
|
|
53
|
+
# @param keys [Array<Symbol, Hash>] attribute names or hash of attributes with validation options
|
|
18
54
|
def optional(*keys)
|
|
19
55
|
apply_validations(keys, required: false)
|
|
20
56
|
end
|
|
21
57
|
|
|
58
|
+
##
|
|
59
|
+
# Runs additional validations *after* the interactor executes.
|
|
60
|
+
#
|
|
61
|
+
# Useful for checking persisted records, custom conditions, or results
|
|
62
|
+
# that depend on post-processing logic.
|
|
63
|
+
#
|
|
64
|
+
# @param keys [Array<Symbol>] context keys to validate
|
|
65
|
+
# @param validations [Hash] validation options (e.g., presence:, type:, inclusion:, persisted:)
|
|
22
66
|
def validates_after(*keys, **validations)
|
|
23
67
|
after do
|
|
24
68
|
keys.each do |key|
|
|
@@ -27,6 +71,15 @@ module InteractorSupport
|
|
|
27
71
|
end
|
|
28
72
|
end
|
|
29
73
|
|
|
74
|
+
##
|
|
75
|
+
# Runs validations *before* the interactor executes.
|
|
76
|
+
#
|
|
77
|
+
# Prevents invalid data from reaching business logic.
|
|
78
|
+
#
|
|
79
|
+
# NOTE: `persisted:` validation is only available in `validates_after`.
|
|
80
|
+
#
|
|
81
|
+
# @param keys [Array<Symbol>] context keys to validate
|
|
82
|
+
# @param validations [Hash] validation options (e.g., presence:, type:, inclusion:)
|
|
30
83
|
def validates_before(*keys, **validations)
|
|
31
84
|
before do
|
|
32
85
|
if validations[:persisted]
|
|
@@ -41,6 +94,11 @@ module InteractorSupport
|
|
|
41
94
|
|
|
42
95
|
private
|
|
43
96
|
|
|
97
|
+
##
|
|
98
|
+
# Applies ActiveModel-based validations and wires up accessors to context.
|
|
99
|
+
#
|
|
100
|
+
# @param keys [Array<Symbol, Hash>] attributes to validate
|
|
101
|
+
# @param required [Boolean] whether presence is enforced
|
|
44
102
|
def apply_validations(keys, required:)
|
|
45
103
|
keys.each do |key|
|
|
46
104
|
if key.is_a?(Hash)
|
|
@@ -61,18 +119,27 @@ module InteractorSupport
|
|
|
61
119
|
validates(key, presence: true) if required
|
|
62
120
|
end
|
|
63
121
|
end
|
|
64
|
-
|
|
122
|
+
|
|
65
123
|
before do
|
|
66
124
|
context.fail!(errors: errors.full_messages) unless valid?
|
|
67
125
|
end
|
|
68
126
|
end
|
|
69
127
|
|
|
128
|
+
##
|
|
129
|
+
# Defines methods to read/write from the interactor context.
|
|
130
|
+
#
|
|
131
|
+
# @param key [Symbol] the context key
|
|
70
132
|
def define_context_methods(key)
|
|
71
133
|
define_method(key) { context[key] }
|
|
72
134
|
define_method("#{key}=") { |value| context[key] = value }
|
|
73
135
|
end
|
|
74
136
|
end
|
|
75
137
|
|
|
138
|
+
##
|
|
139
|
+
# Applies custom inline validations to a context key.
|
|
140
|
+
#
|
|
141
|
+
# @param key [Symbol] the context key
|
|
142
|
+
# @param validations [Hash] options like presence:, type:, inclusion:, persisted:
|
|
76
143
|
def apply_custom_validations(key, validations)
|
|
77
144
|
validation_for_presence(key) if validations[:presence]
|
|
78
145
|
validation_for_inclusion(key, validations[:inclusion]) if validations[:inclusion]
|
|
@@ -80,10 +147,20 @@ module InteractorSupport
|
|
|
80
147
|
validation_for_type(key, validations[:type]) if validations[:type]
|
|
81
148
|
end
|
|
82
149
|
|
|
150
|
+
##
|
|
151
|
+
# Fails if context value is not of expected type.
|
|
152
|
+
#
|
|
153
|
+
# @param key [Symbol]
|
|
154
|
+
# @param type [Class]
|
|
83
155
|
def validation_for_type(key, type)
|
|
84
156
|
context.fail!(errors: ["#{key} was not of type #{type}"]) unless context[key].is_a?(type)
|
|
85
157
|
end
|
|
86
158
|
|
|
159
|
+
##
|
|
160
|
+
# Fails if value is not included in allowed values.
|
|
161
|
+
#
|
|
162
|
+
# @param key [Symbol]
|
|
163
|
+
# @param inclusion [Hash] with `:in` key
|
|
87
164
|
def validation_for_inclusion(key, inclusion)
|
|
88
165
|
unless inclusion.is_a?(Hash) && inclusion[:in].is_a?(Enumerable)
|
|
89
166
|
raise ArgumentError, 'inclusion validation requires an :in key with an array or range'
|
|
@@ -94,23 +171,26 @@ module InteractorSupport
|
|
|
94
171
|
context.fail!(errors: [e.message])
|
|
95
172
|
end
|
|
96
173
|
|
|
174
|
+
##
|
|
175
|
+
# Fails if value is nil or blank.
|
|
176
|
+
#
|
|
177
|
+
# @param key [Symbol]
|
|
97
178
|
def validation_for_presence(key)
|
|
98
179
|
context.fail!(errors: ["#{key} does not exist"]) unless context[key].present?
|
|
99
180
|
end
|
|
100
181
|
|
|
182
|
+
##
|
|
183
|
+
# Fails if value is not a persisted `ApplicationRecord`.
|
|
184
|
+
#
|
|
185
|
+
# @param key [Symbol]
|
|
101
186
|
def validation_for_persistence(key)
|
|
102
187
|
validation_for_presence(key)
|
|
188
|
+
|
|
103
189
|
unless context[key].is_a?(ApplicationRecord)
|
|
104
|
-
context.fail!(
|
|
105
|
-
errors: [
|
|
106
|
-
"#{key} is not an ApplicationRecord, which is required for persisted validation",
|
|
107
|
-
],
|
|
108
|
-
)
|
|
190
|
+
context.fail!(errors: ["#{key} is not an ApplicationRecord, which is required for persisted validation"])
|
|
109
191
|
end
|
|
110
192
|
|
|
111
|
-
context.fail!(
|
|
112
|
-
errors: ["#{key} was not persisted"] + context[key].errors.full_messages,
|
|
113
|
-
) unless context[key].persisted?
|
|
193
|
+
context.fail!(errors: ["#{key} was not persisted"] + context[key].errors.full_messages) unless context[key].persisted?
|
|
114
194
|
end
|
|
115
195
|
end
|
|
116
196
|
end
|