interactor_support 1.0.2 → 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 +6 -0
- data/README.md +177 -319
- 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 +64 -10
- 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,18 +1,50 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
2
|
module Concerns
|
|
3
|
+
##
|
|
4
|
+
# Adds dynamic model-finding helpers (`find_by`, `find_where`) to an interactor.
|
|
5
|
+
#
|
|
6
|
+
# This concern wraps ActiveRecord `.find_by` and `.where` queries into
|
|
7
|
+
# declarative DSL methods that load records into the interactor context.
|
|
8
|
+
#
|
|
9
|
+
# These methods support symbols (for context keys) and lambdas (for dynamic runtime evaluation).
|
|
10
|
+
#
|
|
11
|
+
# @example Find a post by ID from the context
|
|
12
|
+
# find_by :post
|
|
13
|
+
#
|
|
14
|
+
# @example Find by query using context value
|
|
15
|
+
# find_by :post, query: { slug: :slug }, required: true
|
|
16
|
+
#
|
|
17
|
+
# @example Find using a dynamic lambda
|
|
18
|
+
# find_by :post, query: { created_at: -> { 1.week.ago..Time.current } }
|
|
19
|
+
#
|
|
20
|
+
# @example Find all posts for a user with a scope
|
|
21
|
+
# find_where :post, where: { user_id: :user_id }, scope: :published
|
|
22
|
+
#
|
|
23
|
+
# @see InteractorSupport::Actions
|
|
3
24
|
module Findable
|
|
4
25
|
extend ActiveSupport::Concern
|
|
5
26
|
include InteractorSupport::Core
|
|
6
|
-
|
|
27
|
+
|
|
7
28
|
included do
|
|
8
29
|
class << self
|
|
9
|
-
#
|
|
10
|
-
#
|
|
30
|
+
# Adds a `before` callback to find a single record and assign it to context.
|
|
31
|
+
#
|
|
32
|
+
# This method searches for a record based on the provided query parameters.
|
|
33
|
+
# It supports dynamic values using symbols (context keys) and lambdas (for runtime evaluation).
|
|
34
|
+
#
|
|
35
|
+
# @param model [Symbol, String] the name of the model to query (e.g., `:post`)
|
|
36
|
+
# @param query [Hash{Symbol=>Object,Proc}] a hash of attributes to match (can use symbols for context lookup or lambdas)
|
|
37
|
+
# @param context_key [Symbol, nil] the key under which to store the result in context (defaults to the model name)
|
|
38
|
+
# @param required [Boolean] if true, fails the context if no record is found
|
|
11
39
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
40
|
+
# @example Basic ID-based lookup
|
|
41
|
+
# find_by :post
|
|
42
|
+
#
|
|
43
|
+
# @example Query with context value
|
|
44
|
+
# find_by :post, query: { slug: :slug }
|
|
45
|
+
#
|
|
46
|
+
# @example Query with a lambda
|
|
47
|
+
# find_by :post, query: { created_at: -> { 1.week.ago..Time.current } }
|
|
16
48
|
def find_by(model, query: {}, context_key: nil, required: false)
|
|
17
49
|
context_key ||= model
|
|
18
50
|
before do
|
|
@@ -24,9 +56,9 @@ module InteractorSupport
|
|
|
24
56
|
model.to_s.classify.constantize.find_by(
|
|
25
57
|
query.transform_values do |v|
|
|
26
58
|
case v
|
|
27
|
-
when Symbol then context[v]
|
|
28
|
-
when Proc then instance_exec(&v)
|
|
29
|
-
else v
|
|
59
|
+
when Symbol then context[v]
|
|
60
|
+
when Proc then instance_exec(&v)
|
|
61
|
+
else v
|
|
30
62
|
end
|
|
31
63
|
end,
|
|
32
64
|
)
|
|
@@ -37,14 +69,26 @@ module InteractorSupport
|
|
|
37
69
|
end
|
|
38
70
|
end
|
|
39
71
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
72
|
+
# Adds a `before` callback to find multiple records and assign them to context.
|
|
73
|
+
#
|
|
74
|
+
# This method performs a `.where` query with optional `.where.not` and `.scope`,
|
|
75
|
+
# allowing dynamic values using symbols (for context lookup) and lambdas (for runtime evaluation).
|
|
42
76
|
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
77
|
+
# @param model [Symbol, String] the name of the model to query (e.g., `:post`)
|
|
78
|
+
# @param where [Hash{Symbol=>Object,Proc}] conditions for `.where` (can use symbols or lambdas)
|
|
79
|
+
# @param where_not [Hash{Symbol=>Object,Proc}] conditions for `.where.not`
|
|
80
|
+
# @param scope [Symbol, nil] optional named scope to call on the model
|
|
81
|
+
# @param context_key [Symbol, nil] the key under which to store the result in context (defaults to pluralized model name)
|
|
82
|
+
# @param required [Boolean] if true, fails the context if no records are found
|
|
83
|
+
#
|
|
84
|
+
# @example Where query with symbol context values
|
|
85
|
+
# find_where :post, where: { user_id: :user_id }
|
|
86
|
+
#
|
|
87
|
+
# @example Where with a lambda and scope
|
|
88
|
+
# find_where :post, where: { created_at: -> { 5.days.ago..Time.current } }, scope: :published
|
|
89
|
+
#
|
|
90
|
+
# @example Where with exclusions
|
|
91
|
+
# find_where :post, where: { user_id: :user_id }, where_not: { active: false }
|
|
48
92
|
def find_where(model, where: {}, where_not: {}, scope: nil, context_key: nil, required: false)
|
|
49
93
|
context_key ||= model.to_s.pluralize.to_sym
|
|
50
94
|
before do
|
|
@@ -53,9 +97,9 @@ module InteractorSupport
|
|
|
53
97
|
query = query.where(
|
|
54
98
|
where.transform_values do |v|
|
|
55
99
|
case v
|
|
56
|
-
when Symbol then context[v]
|
|
57
|
-
when Proc then instance_exec(&v)
|
|
58
|
-
else v
|
|
100
|
+
when Symbol then context[v]
|
|
101
|
+
when Proc then instance_exec(&v)
|
|
102
|
+
else v
|
|
59
103
|
end
|
|
60
104
|
end,
|
|
61
105
|
) if where.present?
|
|
@@ -1,11 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module InteractorSupport
|
|
2
4
|
module Concerns
|
|
5
|
+
##
|
|
6
|
+
# Adds a DSL method to conditionally skip an interactor.
|
|
7
|
+
#
|
|
8
|
+
# This concern provides a `skip` method that wraps the interactor in an `around` block.
|
|
9
|
+
# You can pass an `:if` or `:unless` condition using a Proc, Symbol, or literal.
|
|
10
|
+
# The condition will be evaluated at runtime to determine whether to run the interactor.
|
|
11
|
+
#
|
|
12
|
+
# - Symbols will be looked up on the interactor or in the context.
|
|
13
|
+
# - Lambdas/Procs are evaluated using `instance_exec` with full access to context.
|
|
14
|
+
#
|
|
15
|
+
# @example Skip if the user is already authenticated (symbol in context)
|
|
16
|
+
# skip if: :user_authenticated
|
|
17
|
+
#
|
|
18
|
+
# @example Skip unless a method returns true
|
|
19
|
+
# skip unless: :should_run?
|
|
20
|
+
#
|
|
21
|
+
# @example Skip based on a lambda
|
|
22
|
+
# skip if: -> { mode == "test" }
|
|
23
|
+
#
|
|
24
|
+
# @see InteractorSupport::Actions
|
|
3
25
|
module Skippable
|
|
4
26
|
extend ActiveSupport::Concern
|
|
5
27
|
include InteractorSupport::Core
|
|
6
28
|
|
|
7
29
|
included do
|
|
8
30
|
class << self
|
|
31
|
+
##
|
|
32
|
+
# Skips the interactor based on a condition provided via `:if` or `:unless`.
|
|
33
|
+
#
|
|
34
|
+
# This wraps the interactor in an `around` hook, and conditionally skips
|
|
35
|
+
# execution based on truthy/falsy evaluation of the provided options.
|
|
36
|
+
#
|
|
37
|
+
# The condition can be a Proc (evaluated in context), a Symbol (used to call a method or context key), or a literal value.
|
|
38
|
+
#
|
|
39
|
+
# @param options [Hash]
|
|
40
|
+
# @option options [Proc, Symbol, Boolean] :if a condition that must be truthy to skip
|
|
41
|
+
# @option options [Proc, Symbol, Boolean] :unless a condition that must be falsy to skip
|
|
42
|
+
#
|
|
43
|
+
# @example Skip if a context value is truthy
|
|
44
|
+
# skip if: :user_authenticated
|
|
45
|
+
#
|
|
46
|
+
# @example Skip unless a method returns true
|
|
47
|
+
# skip unless: :should_run?
|
|
48
|
+
#
|
|
49
|
+
# @example Skip based on a lambda
|
|
50
|
+
# skip if: -> { context[:mode] == "test" }
|
|
9
51
|
def skip(**options)
|
|
10
52
|
around do |interactor|
|
|
11
53
|
unless options[:if].nil?
|
|
@@ -1,11 +1,48 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
2
|
module Concerns
|
|
3
|
+
##
|
|
4
|
+
# Adds transactional support to your interactor using ActiveRecord.
|
|
5
|
+
#
|
|
6
|
+
# The `transaction` method wraps the interactor execution in an `around` block
|
|
7
|
+
# that uses `ActiveRecord::Base.transaction`. If the context fails (via `context.fail!`),
|
|
8
|
+
# the transaction is rolled back automatically using `ActiveRecord::Rollback`.
|
|
9
|
+
#
|
|
10
|
+
# This is useful for ensuring your interactor behaves atomically.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# class CreateUser
|
|
14
|
+
# include Interactor
|
|
15
|
+
# include InteractorSupport::Transactionable
|
|
16
|
+
#
|
|
17
|
+
# transaction
|
|
18
|
+
#
|
|
19
|
+
# def call
|
|
20
|
+
# User.create!(context.user_params)
|
|
21
|
+
# context.fail!(message: "Simulated failure") if something_wrong?
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @see InteractorSupport::Actions
|
|
3
26
|
module Transactionable
|
|
4
27
|
extend ActiveSupport::Concern
|
|
5
28
|
include InteractorSupport::Core
|
|
6
29
|
|
|
7
30
|
included do
|
|
8
31
|
class << self
|
|
32
|
+
# Wraps the interactor in a database transaction.
|
|
33
|
+
#
|
|
34
|
+
# If the context fails (`context.failure?`), a rollback is triggered automatically.
|
|
35
|
+
# You can customize the transaction behavior using standard ActiveRecord options.
|
|
36
|
+
#
|
|
37
|
+
# @param isolation [Symbol, nil] the transaction isolation level (e.g., `:read_committed`, `:serializable`)
|
|
38
|
+
# @param joinable [Boolean] whether this transaction can join an existing one
|
|
39
|
+
# @param requires_new [Boolean] whether to force a new transaction, even if one already exists
|
|
40
|
+
#
|
|
41
|
+
# @example Wrap in a basic transaction
|
|
42
|
+
# transaction
|
|
43
|
+
#
|
|
44
|
+
# @example With custom options
|
|
45
|
+
# transaction requires_new: true, isolation: :serializable
|
|
9
46
|
def transaction(isolation: nil, joinable: true, requires_new: false)
|
|
10
47
|
around do |interactor|
|
|
11
48
|
ActiveRecord::Base.transaction(isolation: isolation, joinable: joinable, requires_new: requires_new) do
|
|
@@ -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?
|
|
@@ -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
|