interactor_support 1.0.3 → 1.0.4

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: 2b479b98d9029e0865ab4526efcc08ec47736ecba96547d2ab9be294f54d2880
4
- data.tar.gz: 74f5d2a0024afb6b7c49ff153a339d6373d25072d37e546827407e65cfd0bc8d
3
+ metadata.gz: 4d6d8c9f46a3e48520652cdb83a9257d487a3411feef0b1efd69c1fc4a209c03
4
+ data.tar.gz: 31b939d573a1dc042b671304ec777bf4a8bf53ad61bd3b4d63fccc3fd4dd2806
5
5
  SHA512:
6
- metadata.gz: e1d24d1b7fe20316f26d14f029387d90e1c88e2dc9654500dd40ae9546e030121efcb5de991d091b752308f0f976533a4dabaebc3cb2bebc269b7f5ca70ab7d3
7
- data.tar.gz: 32c320144c9f90dc53769bd104a308d17ce663e8618cc0f55f8435d89835ed3aa2cb3049df023422de0d34068ced12795e12127e8eb976f18a3db8a56c0e759a
6
+ metadata.gz: 2cf4e789be122df812109df549d3d94a4cf685af0a85eb3fa3d47e811fc73d2231a4fd5af95a2e3c397505102c363bb831df716f16fe701fa0e2fd33e586e757
7
+ data.tar.gz: 8d6e5ee59b6c0523d0d7a9931d6b0ee4f1f98c7aa7d87aabfd45f8dcad8b9f7831444030803abc98bf0594b87a9e084cc9285e8f7382dd378f0d68f4fdc79823
data/CHANGELOG.md CHANGED
@@ -17,3 +17,7 @@
17
17
  - Added support for rewriting attribute names in a request object
18
18
  - Better support for type coersion, using Active model + Array, Hash, and Symbol
19
19
  - Better support for `AnyClass` type validations
20
+
21
+ ## [1.0.4] - 2025-04-05
22
+
23
+ - Added the organizable concern
data/README.md CHANGED
@@ -527,6 +527,131 @@ UserRequest.new(params.require(:user).permit(:name, :email, :age))
527
527
 
528
528
  But with RequestObject, that’s often unnecessary because you’re already defining a schema.
529
529
 
530
+ ## InteractorSupport::Organizable
531
+
532
+ The Organizable concern provides utility methods to simplify working with interactors and request objects. It gives you a clean and consistent pattern for extracting, transforming, and preparing parameters for use in service objects or interactors.
533
+
534
+ Features
535
+
536
+ - organize: Call interactors with request objects, optionally namespaced under a context_key.
537
+ - request_params: Extract, shape, filter, rename, flatten, and merge incoming params in a clear and declarative way.
538
+ - Built for controllers or service entry points.
539
+ - Rails-native feel — works seamlessly with strong params.
540
+
541
+ #### API Reference
542
+
543
+ **#organize(interactor, params:, request_object:, context_key: nil)**
544
+ Calls the given interactor with a request object built from the provided params.
545
+
546
+ | Argument | Type | Description |
547
+ | -------------- | ------------- | --------------------------------------------------------------------------- |
548
+ | interactor | Class | The interactor to call (.call must be defined). |
549
+ | params | Hash | Parameters passed to the request object. |
550
+ | request_object | Class | A request object class that accepts params in its initializer. |
551
+ | context_key | Symbol or nil | Optional key to namespace the request object inside the interactor context. |
552
+
553
+ Examples
554
+
555
+ ```rb
556
+ organize(MyInteractor, params: request_params, request_object: MyRequest)
557
+
558
+ # => MyInteractor.call(MyRequest.new(params))
559
+
560
+ organize(MyInteractor, params: request_params, request_object: MyRequest, context_key: :request)
561
+
562
+ # => MyInteractor.call({ request: MyRequest.new(params) })
563
+ ```
564
+
565
+ #### #request_params(\*top_level_keys, merge: {}, except: [], rewrite: [])
566
+
567
+ Returns a shaped parameter hash derived from params.permit!. You can extract specific top-level keys, rename them, flatten values, apply defaults, and remove unwanted fields.
568
+
569
+ | Argument | Type | Description |
570
+ | ----------------- | -------------------------------- | ------------------------------------------------------------------------- |
571
+ | `*top_level_keys` | `Symbol...` | Optional list of top-level keys to include. If omitted, includes all. |
572
+ | `merge:` | `Hash` | Extra values to merge into the result. |
573
+ | `except:` | `Array<Symbol or Array<Symbol>>` | Keys or nested key paths to exclude. |
574
+ | `rewrite:` | `Array<Hash>` | Rules for renaming, flattening, filtering, merging, or defaulting values. |
575
+
576
+ Rewrite Options
577
+
578
+ Each rewrite entry is a hash in the form { key => options }, where options may include:
579
+ | Option | Type | Description |
580
+ |-----------|---------------------------|-------------------------------------------------------------------|
581
+ | `as` | `Symbol` | Rename the key to a new top-level key. |
582
+ | `only` | `Array<Symbol>` | Include only these subkeys in the result. |
583
+ | `except` | `Array<Symbol>` | Remove these subkeys from the result. |
584
+ | `flatten` | `true` or `Array<Symbol>` | Flatten all subkeys into top-level (or just the specified ones). |
585
+ | `default` | `Hash` | Use this value if the original key is missing or nil. |
586
+ | `merge` | `Hash` | Merge this hash into the result (after filtering and flattening). |
587
+
588
+ Example: full usage
589
+
590
+ ```rb
591
+ # Incoming params:
592
+ params = {
593
+ order: {
594
+ product_id: 1,
595
+ quantity: 2,
596
+ internal: "should be removed"
597
+ },
598
+ metadata: {
599
+ source: "mobile",
600
+ internal: "hidden",
601
+ location: { ip: "1.2.3.4" }
602
+ },
603
+ flags: {
604
+ foo: true
605
+ },
606
+ internal: "global_internal",
607
+ session: nil
608
+ }
609
+
610
+ # Incantation:
611
+ request_params(:order, :metadata, :flags, :session,
612
+ merge: { user: current_user }, # <- Add the user
613
+ except: [[:order, :internal], :internal], # <- remove `order.internal`, and the top level key `internal`
614
+ rewrite: [
615
+ { order: { flatten: true } }, # <- moves all the values from order to top level keys
616
+ { metadata: { as: :meta, only: [:source, :location], flatten: [:location] } }, # <- Rename metadata to meta, pluck source and location, move location's values to meta
617
+ { flags: { merge: { debug: true } } }, # <- add flags.debug = true
618
+ { session: { default: { id: nil } } } # <- create a default value for session
619
+ ]
620
+ )
621
+
622
+ # Result
623
+ {
624
+ product_id: 1,
625
+ quantity: 2,
626
+ meta: {
627
+ source: "mobile",
628
+ ip: "1.2.3.4"
629
+ },
630
+ flags: {
631
+ foo: true,
632
+ debug: true
633
+ },
634
+ session: {
635
+ id: nil
636
+ },
637
+ user: current_user
638
+ }
639
+ ```
640
+
641
+ ⚠️ Array flattening is not supported
642
+
643
+ Flattening arrays of hashes (e.g., { events: [{ id: 1 }] }) is intentionally not supported to avoid accidental key collisions. If needed, transform such structures manually before passing to request_params.
644
+
645
+ **Usage**
646
+
647
+ Include in a controller or service base class
648
+
649
+ ```rb
650
+ class ApplicationController < ActionController::Base
651
+ include InteractorSupport::Concerns::Organizable
652
+ end
653
+ ```
654
+
530
655
  ## 🤝 **Contributing**
531
656
 
532
657
  Pull requests are welcome on [GitHub](https://github.com/charliemitchell/interactor_support).
@@ -7,11 +7,11 @@ module InteractorSupport
7
7
  # This module is intended to be included into an `Interactor` or `Organizer`,
8
8
  # providing access to a suite of declarative action helpers:
9
9
  #
10
- # - {Skippable} — Conditionally skip execution
11
- # - {Transactionable} — Wrap logic in an ActiveRecord transaction
12
- # - {Updatable} — Update records using context-driven attributes
13
- # - {Findable} — Find one or many records into context
14
- # - {Transformable} — Normalize or modify context values before execution
10
+ # - {InteractorSupport::Concerns::Skippable} — Conditionally skip execution
11
+ # - {InteractorSupport::Concerns::Transactionable} — Wrap logic in an ActiveRecord transaction
12
+ # - {InteractorSupport::Concerns::Updatable} — Update records using context-driven attributes
13
+ # - {InteractorSupport::Concerns::Findable} — Find one or many records into context
14
+ # - {InteractorSupport::Concerns::Transformable} — Normalize or modify context values before execution
15
15
  #
16
16
  # @example Use in an interactor
17
17
  # class UpdateUser
@@ -25,6 +25,7 @@ module InteractorSupport
25
25
  # update :user, attributes: { email: :email }
26
26
  # end
27
27
  #
28
+ #
28
29
  # @see InteractorSupport::Concerns::Skippable
29
30
  # @see InteractorSupport::Concerns::Transactionable
30
31
  # @see InteractorSupport::Concerns::Updatable
@@ -32,6 +33,7 @@ module InteractorSupport
32
33
  # @see InteractorSupport::Concerns::Transformable
33
34
  module Actions
34
35
  extend ActiveSupport::Concern
36
+
35
37
  included do
36
38
  include InteractorSupport::Concerns::Skippable
37
39
  include InteractorSupport::Concerns::Transactionable
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InteractorSupport
4
+ module Concerns
5
+ ##
6
+ # The `Organizable` module provides utility methods for organizing interactors
7
+ # and shaping request parameters in a structured way.
8
+ #
9
+ # It is intended to be included into a controller or a base service class that
10
+ # delegates to interactors using request objects.
11
+ #
12
+ # @example Include in a controller
13
+ # class ApplicationController < ActionController::Base
14
+ # include InteractorSupport::Organizable
15
+ # end
16
+ #
17
+ # @see InteractorSupport::Organizable#organize
18
+ # @see InteractorSupport::Organizable#request_params
19
+ module Organizable
20
+ include ActiveSupport::Concern
21
+
22
+ # Calls the given interactor with a request object.
23
+ # Optionally wraps the request object under a key in the interactor context.
24
+ #
25
+ # @param interactor [Class] The interactor class to call.
26
+ # @param params [Hash] Parameters to initialize the request object.
27
+ # @param request_object [Class] A request object class that responds to `#new(params)`.
28
+ # @param context_key [Symbol, nil] Optional key to assign the request object under in the context.
29
+ #
30
+ # @return [void]
31
+ #
32
+ # @example
33
+ # organize(MyInteractor, params: request_params, request_object: MyRequest)
34
+ # # => Calls MyInteractor with an instance of MyRequest initialized with request_params.
35
+ #
36
+ # @example
37
+ # organize(MyInteractor, params: request_params, request_object: MyRequest, context_key: :request)
38
+ # # => Calls MyInteractor with an instance of MyRequest initialized with request_params at :context_key.
39
+ # # # => The context will contain { request: MyRequest.new(request_params) }
40
+ def organize(interactor, params:, request_object:, context_key: nil)
41
+ @context = interactor.call(
42
+ context_key ? { context_key => request_object.new(params) } : request_object.new(params),
43
+ )
44
+ end
45
+
46
+ # Builds a structured and optionally transformed parameter hash from Rails' `params`.
47
+ #
48
+ # This method supports extracting specific top-level keys, applying optional rewrite
49
+ # transformations, merging in additional values, and excluding unwanted keys.
50
+ #
51
+ # @param top_level_keys [Array<Symbol>] Top-level keys to extract from `params`. If empty, all keys are included.
52
+ # @param merge [Hash] Additional values to merge into the final result.
53
+ # @param except [Array<Symbol, Array<Symbol>>] Keys or nested key paths to exclude from the result.
54
+ # @param rewrite [Array<Hash>] A set of transformation rules applied to the top-level keys.
55
+ #
56
+ # @return [Hash] The final, shaped parameters hash.
57
+ #
58
+ # @example Extracting a specific top-level key
59
+ # # Given: params = { order: { product_id: 1, quantity: 2 } }
60
+ # request_params(:order)
61
+ # # => { order: { product_id: 1, quantity: 2 } }
62
+ #
63
+ # @example Without top-level keys (includes all)
64
+ # # Given: params = { order: { product_id: 1 }, app_id: 123 }
65
+ # request_params()
66
+ # # => { order: { product_id: 1 }, app_id: 123 }
67
+ #
68
+ # @example Merging and excluding
69
+ # # Given: params = { order: { product_id: 1, quantity: 2 }, internal: "yes" }
70
+ # request_params(:order, merge: { user_id: 123 }, except: [[:order, :quantity], :internal])
71
+ # # => { order: { product_id: 1 }, user_id: 123 }
72
+ #
73
+ # @example Flattening a nested hash into the top-level
74
+ # # Given: params = { order: { product_id: 1, quantity: 2 }, app_id: 123 }
75
+ # request_params(:order, rewrite: [{ order: { flatten: true } }])
76
+ # # => { product_id: 1, quantity: 2 }
77
+ #
78
+ # @example Rename a top-level key and filter nested keys
79
+ # # Given: params = { metadata: { source: "mobile", internal: "x" } }
80
+ # request_params(:metadata, rewrite: [
81
+ # { metadata: { as: :meta, only: [:source] } }
82
+ # ])
83
+ # # => { meta: { source: "mobile" } }
84
+ #
85
+ # @example Provide a default value if a key is missing
86
+ # # Given: params = {}
87
+ # request_params(:session, rewrite: [
88
+ # { session: { default: { id: nil } } }
89
+ # ])
90
+ # # => { session: { id: nil } }
91
+ #
92
+ # @example Merge values into a nested structure
93
+ # # Given: params = { flags: { foo: true } }
94
+ # request_params(:flags, rewrite: [
95
+ # { flags: { merge: { debug: true } } }
96
+ # ])
97
+ # # => { flags: { foo: true, debug: true } }
98
+ #
99
+ # @example Combine multiple rewrite rules
100
+ # # Given:
101
+ # # params = {
102
+ # # order: { product_id: 1, quantity: 2 },
103
+ # # metadata: { source: "mobile", location: { ip: "1.2.3.4" } },
104
+ # # tracking: { click_id: "abc", session_id: "def" }
105
+ # # }
106
+ # request_params(:order, :metadata, :tracking, rewrite: [
107
+ # { order: { flatten: true } },
108
+ # { metadata: { as: :meta, only: [:source, :location], flatten: [:location] } }
109
+ # ])
110
+ # # => {
111
+ # # product_id: 1,
112
+ # # quantity: 2,
113
+ # # meta: { source: "mobile", ip: "1.2.3.4" },
114
+ # # tracking: { click_id: "abc", session_id: "def" }
115
+ # # }
116
+ def request_params(*top_level_keys, merge: {}, except: [], rewrite: [])
117
+ permitted = params.permit!.to_h.deep_symbolize_keys
118
+ data = top_level_keys.any? ? permitted.slice(*top_level_keys) : permitted
119
+
120
+ apply_rewrites!(data, rewrite)
121
+
122
+ data
123
+ .deep_merge(merge)
124
+ .then { |result| except.any? ? deep_except(result, except) : result }
125
+ end
126
+
127
+ private
128
+
129
+ def apply_rewrites!(data, rewrites)
130
+ rewrites.each do |rule|
131
+ key, config = rule.first
132
+ config = { flatten: true } if config == :flatten
133
+
134
+ original = data.key?(key) ? data.delete(key) : nil
135
+ transformed = original.deep_dup if original.is_a?(Hash)
136
+ transformed ||= original
137
+
138
+ # Filtering
139
+ transformed.slice!(*config[:only]) if config[:only] && transformed.respond_to?(:slice!)
140
+ transformed.except!(*config[:except]) if config[:except] && transformed.respond_to?(:except!)
141
+
142
+ # Flatten specific nested keys
143
+ if config[:flatten].is_a?(Array) && transformed.is_a?(Hash)
144
+ config[:flatten].each do |subkey|
145
+ nested = transformed.delete(subkey)
146
+ if nested.is_a?(Hash)
147
+ transformed.merge!(nested)
148
+ elsif nested.is_a?(Array)
149
+ raise ArgumentError,
150
+ "Cannot flatten array for the key `#{subkey}`. Flattening arrays of hashes is not supported."
151
+ end
152
+ end
153
+ end
154
+
155
+ # Apply default if nil or missing
156
+ transformed ||= config[:default]
157
+
158
+ # Merge additional keys
159
+ if config[:merge]
160
+ transformed = transformed.is_a?(Hash) ? transformed.merge(config[:merge]) : config[:merge]
161
+ end
162
+
163
+ # Fully flatten to top level
164
+ if config[:flatten] == true && transformed.is_a?(Hash)
165
+ data.merge!(transformed)
166
+ else
167
+ target_key = config[:as] || key
168
+ data[target_key] = transformed
169
+ end
170
+ end
171
+ end
172
+
173
+ def deep_except(hash, paths)
174
+ paths.reduce(hash) { |acc, path| remove_nested_key(acc, Array(path)) }
175
+ end
176
+
177
+ def remove_nested_key(hash, path)
178
+ return hash unless path.is_a?(Array) && path.any?
179
+
180
+ key, *rest = path
181
+ return hash unless hash.key?(key)
182
+
183
+ duped = hash.dup
184
+ if rest.empty?
185
+ duped.delete(key)
186
+ elsif duped[key].is_a?(Hash)
187
+ duped[key] = remove_nested_key(duped[key], rest)
188
+ end
189
+
190
+ duped
191
+ end
192
+ end
193
+ end
194
+ end
@@ -82,6 +82,7 @@ module InteractorSupport
82
82
  value
83
83
  end
84
84
  end
85
+
85
86
  return Struct.new(*attrs.keys).new(*attrs.values) if key_type == :struct
86
87
 
87
88
  attrs
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InteractorSupport
4
- VERSION = '1.0.3'
4
+ VERSION = '1.0.4'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: interactor_support
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Charlie Mitchell
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-04-04 00:00:00.000000000 Z
11
+ date: 2025-04-05 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -30,6 +30,7 @@ files:
30
30
  - lib/interactor_support.rb
31
31
  - lib/interactor_support/actions.rb
32
32
  - lib/interactor_support/concerns/findable.rb
33
+ - lib/interactor_support/concerns/organizable.rb
33
34
  - lib/interactor_support/concerns/skippable.rb
34
35
  - lib/interactor_support/concerns/transactionable.rb
35
36
  - lib/interactor_support/concerns/transformable.rb