interaktor 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be48f704b8d52c81c07e9708d1e90230ead270e4da3ddf74e8e4e6251e677978
4
- data.tar.gz: '09e99c6f8129b44ff287aa9e8a742327cac3a232f13d7596ece9b428a575bc28'
3
+ metadata.gz: 750eeac422a483d0c47ed00a3e457e97c43a134e611962f3a2316335d5c1a942
4
+ data.tar.gz: 6fb7f28d30d78c6dcca2ea25383bdb85576e83600d7de3cad58a13ffa5e14b94
5
5
  SHA512:
6
- metadata.gz: ea1fe86854da930c537f9fb69864accdf8cb1948d8dacae4b64685ff88508964e99eb968826a12e5653791f492c1e8af6426b734838c5ac152c581dfee6399ba
7
- data.tar.gz: 0d8c4406b6d3ee81f39c6adc56118ce4e204465743eed6dc5f342f3b3b5a9a03722f9b04ed48dab265ef9bfbcb54e047476245fdf000295b150e2a835f8087e6
6
+ metadata.gz: 239222c9b5d7633b6c011d9e14ff1ec65f9612a3e1f13709ce8326c35bef28455b84f749a063927bb97e5387b8fd561019b3ead8ce681c7efc23c523736c777c
7
+ data.tar.gz: aaf8d1f62d5e86fbfa079ad2c3222d920eb3119999c7a4dc3809cb32799abe7bc8102db7bb431d3cf07f27cc06f9ef38abce9e22955396464440ccb61b9d6634
@@ -2,7 +2,6 @@ name: Publish
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [master]
6
5
  tags:
7
6
  - v*
8
7
 
@@ -7,10 +7,12 @@ jobs:
7
7
  strategy:
8
8
  fail-fast: false
9
9
  matrix:
10
- os: [ubuntu, macos]
11
- ruby: [2.5, 2.6, 2.7, head, debug]
12
- runs-on: ${{ matrix.os }}-latest
10
+ os: [ubuntu-latest, macos-latest]
11
+ ruby: [2.5, 2.6, 2.7, 3.0, head, debug]
12
+ runs-on: ${{ matrix.os }}
13
13
  continue-on-error: ${{ endsWith(matrix.ruby, 'head') || matrix.ruby == 'debug' }}
14
+ env:
15
+ BUNDLE_GEMFILE: "Gemfile.ci"
14
16
  steps:
15
17
  - uses: actions/checkout@v2
16
18
  - name: Set up Ruby
@@ -18,7 +20,5 @@ jobs:
18
20
  with:
19
21
  ruby-version: ${{ matrix.ruby }}
20
22
  bundler-cache: true
21
- - name: Install dependencies
22
- run: bundle install
23
23
  - name: Run tests
24
24
  run: bundle exec rspec
data/.gitignore CHANGED
@@ -4,6 +4,7 @@
4
4
  .config
5
5
  .yardoc
6
6
  Gemfile.lock
7
+ Gemfile.ci.lock
7
8
  InstalledFiles
8
9
  _yardoc
9
10
  coverage
@@ -15,3 +16,5 @@ spec/reports
15
16
  test/tmp
16
17
  test/version_tmp
17
18
  tmp
19
+ spec/examples.txt
20
+ /vendor
data/Gemfile CHANGED
@@ -2,12 +2,12 @@ source "https://rubygems.org"
2
2
 
3
3
  gemspec
4
4
 
5
+ gem "guard-rspec", require: false
5
6
  gem "rubocop"
6
7
  gem "rubocop-performance"
7
8
  gem "rubocop-rspec"
8
9
  gem "rufo", "~> 0.12.0"
9
10
  gem "solargraph"
10
- gem "guard-rspec", require: false
11
11
 
12
12
  group :test do
13
13
  gem "pry-byebug", platforms: [:mri]
data/Gemfile.ci ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "rspec", "~> 3.9.0"
6
+ gem "simplecov"
data/README.md CHANGED
@@ -5,11 +5,11 @@
5
5
 
6
6
  **DISCLAIMER: Interaktor is under active development. Feel free to use it, but until 1.0 is released, any update could break compatibility with an older version.**
7
7
 
8
- **Interaktor** is a fork of [Interaktor by collectiveidea](https://github.com/collectiveidea/interaktor). While Interactor is still used by collectiveidea internally, communication and progress has been slow in adapting to pull requests and issues. This inactivity combined with my desire to dial back on the Interactor's inherent permissivity led me to fork it and create Interaktor.
8
+ **Interaktor** is a fork of [Interactor by collectiveidea](https://github.com/collectiveidea/interactor). While Interactor is still used by collectiveidea internally, communication and progress has been slow in adapting to pull requests and issues. This inactivity combined with my desire to dial back on the Interactor's inherent permissivity led me to fork it and create Interaktor.
9
9
 
10
10
  Fundamentally, Interaktor is the same as Interactor, but with the following changes:
11
11
 
12
- - Required explicit definition of interaktor "attributes" which replaces the concept of the interaktor context. Attributes can be required or optional, and support options like default values.
12
+ - Required explicit definition of interaktor "attributes" which replaces the concept of the interaktor context. Attributes are defined using a schema DSL provided by [dry-schema](https://github.com/dry-rb/dry-schema), which allows for complex validation, if desired.
13
13
  - The interaktor "context" is no longer a public-facing concept, all data/attribute accessors/setters are defined as attributes
14
14
  - Attributes passed to `#fail!` must be defined in advance
15
15
  - Interaktors support early-exit functionality through the use of `#success!`, which functions the same as `#fail!` in that you must define the required success attributes on the interaktor
@@ -34,15 +34,18 @@ Interaktors are used to encapsulate your application's [business logic](http://e
34
34
 
35
35
  Depending on its definition, an interaktor may require attributes to be passed in when it is invoked. These attributes contain everything the interaktor needs to do its work.
36
36
 
37
- You may define `required` or `optional` attributes.
37
+ Attributes are defined using a schema DSL provided by the [dry-schema](https://github.com/dry-rb/dry-schema) gem. It allows the construction of schemas for validating attributes. The schema is typically provided as a block argument to the `input` class method as seen below.
38
+
39
+ This example is an extremely simple case, and dry-schema supports highly complex schema validation, like type checking, nested hash data validation, and more. For more information on defining an attribute schema, please see the [dry-schema documentation website](https://dry-rb.org/gems/dry-schema). This link should take you to the latest version of dry-schema, but be sure to check that the version of dry-schema in your application bundle matches the documentation you are viewing.
38
40
 
39
41
  ```ruby
40
42
  class CreateUser
41
43
  include Interaktor
42
44
 
43
- required :name
44
-
45
- optional :email
45
+ input do
46
+ required(:name)
47
+ optional(:email)
48
+ end
46
49
 
47
50
  def call
48
51
  User.create!(
@@ -53,9 +56,12 @@ class CreateUser
53
56
  end
54
57
 
55
58
  CreateUser.call(name: "Foo Bar")
56
-
57
59
  ```
58
60
 
61
+ `input` will also accept a `Dry::Schema::Params` object directly, if for some reason the schema needs to be constructed elsewhere.
62
+
63
+ **A note about type checking**: Type checking is cool, but Ruby is a dynamic language, and Ruby developers tend to utilize the idea of [duck typing](https://en.wikipedia.org/wiki/Duck_typing). Forcing the attributes of an interaktor to be of a certain type in order to validate might sound like a good idea, but it can often cause problems in situations where you might like to use duck typing, for example, when using stubs in tests.
64
+
59
65
  #### Output attributes
60
66
 
61
67
  Based on the outcome of the interaktor's work, we can require certain attributes. In the example below, we must succeed with a `user_id` attribute, and if we fail, we must provide an `error_messages` attribute.
@@ -66,11 +72,17 @@ The use of `#success!` allows you to early-return from an interaktor's work. If
66
72
  class CreateUser
67
73
  include Interaktor
68
74
 
69
- required :name
75
+ input do
76
+ required(:name).filled(:string)
77
+ end
70
78
 
71
- success :user_id
79
+ success do
80
+ required(:user_id).value(:integer)
81
+ end
72
82
 
73
- failure :error_messages
83
+ failure do
84
+ required(:error_messages).value(array[:string])
85
+ end
74
86
 
75
87
  def call
76
88
  user = User.new(name: name)
@@ -98,7 +110,7 @@ end
98
110
 
99
111
  Normally, however, these exceptions are not seen. In the recommended usage, the caller invokes the interaktor using the class method `.call`, then checks the `#success?` method of the returned object. This works because the `call` class method swallows exceptions. When unit testing an interaktor, if calling custom business logic methods directly and bypassing `call`, be aware that `fail!` will generate such exceptions.
100
112
 
101
- See _Interaktors in the controller_, below, for the recommended usage of `call` and `success?`.
113
+ See _Interaktors in the controller_, below, for the recommended usage of `.call` and `#success?`.
102
114
 
103
115
  ### Hooks
104
116
 
@@ -235,13 +247,19 @@ A basic interaktor is a class that includes `Interaktor` and defines `call`.
235
247
  class AuthenticateUser
236
248
  include Interaktor
237
249
 
238
- required :email
239
- required :password
250
+ input do
251
+ required(:email).filled(:string)
252
+ required(:password).filled(:string)
253
+ end
240
254
 
241
- success :user
242
- success :token
255
+ success do
256
+ required(:user)
257
+ required(:token).filled(:string)
258
+ end
243
259
 
244
- failure :message
260
+ failure do
261
+ required(:message).filled(:string)
262
+ end
245
263
 
246
264
  def call
247
265
  if user = User.authenticate(email, password)
@@ -263,7 +281,9 @@ An organizer is an important variation on the basic interaktor. Its single purpo
263
281
  class PlaceOrder
264
282
  include Interaktor::Organizer
265
283
 
266
- required :order_params
284
+ input do
285
+ required(:order_params).filled(:hash)
286
+ end
267
287
 
268
288
  organize CreateOrder, ChargeCard, SendThankYou
269
289
  end
@@ -304,9 +324,13 @@ In addition, any interaktors that had already run are given the chance to undo t
304
324
  class CreateOrder
305
325
  include Interaktor
306
326
 
307
- required :order_params
327
+ input do
328
+ required(:order_params).filled(:hash)
329
+ end
308
330
 
309
- success :order
331
+ success do
332
+ required(:order)
333
+ end
310
334
 
311
335
  def call
312
336
  order = Order.create(order_params)
@@ -334,13 +358,19 @@ When written correctly, an interaktor is easy to test because it only _does_ one
334
358
  class AuthenticateUser
335
359
  include Interaktor
336
360
 
337
- required :email
338
- required :password
361
+ input do
362
+ required(:email).filled(:string)
363
+ required(:password).filled(:string)
364
+ end
339
365
 
340
- success :user
341
- success :token
366
+ success do
367
+ required(:user)
368
+ required(:token).filled(:string)
369
+ end
342
370
 
343
- failure :message
371
+ failure do
372
+ required(:message).filled(:string)
373
+ end
344
374
 
345
375
  def call
346
376
  if user = User.authenticate(email, password)
@@ -406,12 +436,18 @@ It's a good idea to define your own interfaces to your models. Doing so makes it
406
436
  class AuthenticateUser
407
437
  include Interaktor
408
438
 
409
- required :email
410
- required :password
439
+ input do
440
+ required(:email).filled(:string)
441
+ required(:password).filled(:string)
442
+ end
411
443
 
412
- success :user
444
+ success do
445
+ required(:user)
446
+ end
413
447
 
414
- failure :message
448
+ failure do
449
+ required(:message).filled(:string)
450
+ end
415
451
 
416
452
  def call
417
453
  user = User.find_by(email: email)
@@ -515,4 +551,4 @@ This controller test will have to change very little during the life of the appl
515
551
 
516
552
  ### Rails
517
553
 
518
- Interactor provided [interactor-rails](https://github.com/collectiveidea/interactor-rails), which ensures `app/interactors` is included in your autoload paths, and provides generators for new interactors. I have no intention of maintaining generators but if someone feels strongly enough to submit a pull request to include the functionality in _this_ gem (not a separate Rails one) then I will be happy to take a look. Making sure `app/interactors` is included in your autoload paths is something I would like to do soon.
554
+ Interactor provided [interactor-rails](https://github.com/collectiveidea/interactor-rails), which ensures `app/interactors` is included in your autoload paths, and provides generators for new interactors. I have no intention of maintaining generators but if someone feels strongly enough to submit a pull request to include the functionality in _this_ gem (not a separate Rails one) then I will be happy to take a look. Making sure `app/interaktors` is included in your autoload paths is something I would like to do soon.
data/interaktor.gemspec CHANGED
@@ -1,9 +1,9 @@
1
1
  Gem::Specification.new do |spec|
2
2
  spec.name = "interaktor"
3
- spec.version = "0.2.0"
3
+ spec.version = "0.3.0"
4
4
 
5
5
  spec.author = "Taylor Thurlow"
6
- spec.email = "taylorthurlow@me.com"
6
+ spec.email = "thurlow@hey.com"
7
7
  spec.description = "A common interface for building service objects."
8
8
  spec.summary = "Simple service object implementation"
9
9
  spec.homepage = "https://github.com/taylorthurlow/interaktor"
@@ -13,6 +13,7 @@ Gem::Specification.new do |spec|
13
13
  spec.required_ruby_version = ">= 2.5"
14
14
  spec.require_path = "lib"
15
15
 
16
+ spec.add_runtime_dependency "dry-schema", "~> 1.0"
16
17
  spec.add_runtime_dependency "zeitwerk", "~> 2.0"
17
18
 
18
19
  spec.add_development_dependency "rake", "~> 13.0"
data/lib/interaktor.rb CHANGED
@@ -32,11 +32,11 @@ module Interaktor
32
32
  #
33
33
  # @return [void]
34
34
  def fail!(failure_attributes = {})
35
- # Make sure we have all required attributes
36
- missing_attrs = self.class
37
- .failure_attributes
38
- .reject { |failure_attr| failure_attributes.key?(failure_attr) }
39
- raise Interaktor::Error::MissingAttributeError.new(self.class.to_s, missing_attrs) if missing_attrs.any?
35
+ # Silently remove any attributes that are not included in the schema
36
+ allowed_keys = self.class.failure_schema.key_map.keys.map { |k| k.name.to_sym }
37
+ failure_attributes.select! { |k, _| allowed_keys.include?(k.to_sym) }
38
+
39
+ self.class.validate_failure_schema(failure_attributes)
40
40
 
41
41
  @context.fail!(failure_attributes)
42
42
  end
@@ -48,16 +48,11 @@ module Interaktor
48
48
  #
49
49
  # @return [void]
50
50
  def success!(success_attributes = {})
51
- # Make sure we have all required attributes
52
- missing_attrs = self.class
53
- .success_attributes
54
- .reject { |success_attr| success_attributes.key?(success_attr) }
55
- raise Interaktor::Error::MissingAttributeError.new(self.class.to_s, missing_attrs) if missing_attrs.any?
51
+ # Silently remove any attributes that are not included in the schema
52
+ allowed_keys = self.class.success_schema.key_map.keys.map { |k| k.name.to_sym }
53
+ success_attributes.select! { |k, _| allowed_keys.include?(k.to_sym) }
56
54
 
57
- # Make sure we haven't provided any unknown attributes
58
- unknown_attrs = success_attributes.keys
59
- .reject { |success_attr| self.class.success_attributes.include?(success_attr) }
60
- raise Interaktor::Error::UnknownAttributeError.new(self.class.to_s, unknown_attrs) if unknown_attrs.any?
55
+ self.class.validate_success_schema(success_attributes)
61
56
 
62
57
  @context.success!(success_attributes)
63
58
  end
@@ -104,8 +99,8 @@ module Interaktor
104
99
  call
105
100
  end
106
101
 
107
- if !@context.early_return? && self.class.success_attributes.any?
108
- raise Interaktor::Error::MissingAttributeError.new(self, self.class.success_attributes)
102
+ if !@context.early_return? && self.class.required_success_attributes.any?
103
+ raise Interaktor::Error::MissingExplicitSuccessError.new(self, self.class.required_success_attributes)
109
104
  end
110
105
 
111
106
  @context.called!(self)
@@ -1,3 +1,7 @@
1
+ require "dry-schema"
2
+
3
+ Dry::Schema.load_extensions(:info)
4
+
1
5
  module Interaktor::Callable
2
6
  # When the module is included in a class, add the relevant class methods to
3
7
  # that class.
@@ -8,131 +12,277 @@ module Interaktor::Callable
8
12
  end
9
13
 
10
14
  module ClassMethods
15
+ ####################
16
+ # INPUT ATTRIBUTES #
17
+ ####################
18
+
11
19
  # The list of attributes which are required to be passed in when calling
12
20
  # the interaktor.
13
21
  #
14
22
  # @return [Array<Symbol>]
15
- def required_attributes
16
- @required_attributes ||= []
23
+ def required_input_attributes
24
+ @required_input_attributes ||= input_schema.info[:keys]
25
+ .select { |_, info| info[:required] }
26
+ .keys
17
27
  end
18
28
 
19
- # The list of attributes which are NOT required to be passed in when
29
+ # The list of attributes which are not required to be passed in when
20
30
  # calling the interaktor.
21
31
  #
22
32
  # @return [Array<Symbol>]
23
- def optional_attributes
24
- @optional_attributes ||= []
33
+ def optional_input_attributes
34
+ # Adding an optional attribute with NO predicates with Dry::Schema is
35
+ # sort of a "nothing statement" - the schema can sort of ignore it. The
36
+ # problem is that the optional-with-no-predicate key is not included in
37
+ # the #info results, so we need to find an list of keys elsewhere, find
38
+ # the ones that are listed there but not in the #info results, and find
39
+ # the difference. The result are the keys that are omitted from the #info
40
+ # result because they are optional and have no predicates.
41
+ #
42
+ # See https://github.com/dry-rb/dry-schema/issues/347
43
+ @optional_input_attributes ||= begin
44
+ attributes_in_info = input_schema.info[:keys].keys
45
+ all_attributes = input_schema.key_map.keys.map(&:id)
46
+ optional_attributes_by_exclusion = all_attributes - attributes_in_info
47
+
48
+ explicitly_optional_attributes = input_schema.info[:keys].reject { |_, info| info[:required] }.keys
49
+
50
+ explicitly_optional_attributes + optional_attributes_by_exclusion
51
+ end
25
52
  end
26
53
 
27
- # A list of optional attributes and their default values.
54
+ # The complete list of input attributes.
28
55
  #
29
56
  # @return [Array<Symbol>]
30
- def optional_defaults
31
- @optional_defaults ||= {}
57
+ def input_attributes
58
+ required_input_attributes + optional_input_attributes
32
59
  end
33
60
 
34
- # A list of attributes which could be passed when calling the interaktor.
61
+ # Get the input attribute schema. Fall back to an empty schema with a
62
+ # configuration that will deny ALL provided attributes - not defining an
63
+ # input schema should mean the interaktor has no input attributes.
35
64
  #
36
- # @return [Array<Symbol>]
37
- def input_attributes
38
- required_attributes + optional_attributes
65
+ # @return [Dry::Schema::Params]
66
+ def input_schema
67
+ @input_schema || Dry::Schema.Params
39
68
  end
40
69
 
41
- # The list of attributes which are required to be passed in when calling
42
- # `#fail!` from within the interaktor.
70
+ # @param context [Hash]
43
71
  #
44
- # @return [Array<Symbol>]
45
- def failure_attributes
46
- @failure_attributes ||= []
72
+ # @return [void]
73
+ def validate_input_schema(context)
74
+ return unless input_schema
75
+
76
+ result = input_schema.call(context)
77
+
78
+ if result.errors.any?
79
+ raise Interaktor::Error::AttributeSchemaValidationError.new(
80
+ self,
81
+ result.errors.to_h,
82
+ )
83
+ end
47
84
  end
48
85
 
49
- # The list of attributes which are required to be passed in when calling
50
- # `#fail!` from within the interaktor.
86
+ # @param schema [Dry::Schema::Params, nil] a predefined schema object
87
+ # @yield a new Dry::Schema::Params definition block
88
+ def input(schema = nil, &block)
89
+ raise "No schema or schema definition block provided to interaktor input." if schema.nil? && !block
90
+
91
+ raise "Provided both a schema and a schema definition block for interaktor input." if schema && block
92
+
93
+ if schema
94
+ raise "Provided argument is not a Dry::Schema::Params object." unless schema.is_a?(Dry::Schema::Params)
95
+
96
+ @input_schema = schema
97
+ elsif block
98
+ @input_schema = Dry::Schema.Params { instance_eval(&block) }
99
+ end
100
+
101
+ # define the getters and setters for the input attributes
102
+ @input_schema.key_map.keys.each do |key| # rubocop:disable Style/HashEachMethods
103
+ attribute_name = key.id
104
+
105
+ # Define getter
106
+ define_method(attribute_name) { @context.send(attribute_name) }
107
+
108
+ # Define setter
109
+ define_method("#{attribute_name}=".to_sym) do |value|
110
+ @context.send("#{attribute_name}=".to_sym, value)
111
+ end
112
+ end
113
+ end
114
+
115
+ ######################
116
+ # FAILURE ATTRIBUTES #
117
+ ######################
118
+
119
+ # The list of attributes which are required to be provided when failing the
120
+ # interaktor.
51
121
  #
52
122
  # @return [Array<Symbol>]
53
- def success_attributes
54
- @success_attributes ||= []
123
+ def required_failure_attributes
124
+ @required_failure_attributes ||= failure_schema.info[:keys]
125
+ .select { |_, info| info[:required] }
126
+ .keys
55
127
  end
56
128
 
57
- # A DSL method for documenting required interaktor attributes.
58
- #
59
- # @param attributes [Symbol, Array<Symbol>] the list of attribute names
60
- # @param options [Hash]
129
+ # The list of attributes which are not required to be provided when failing
130
+ # the interaktor.
61
131
  #
62
- # @return [void]
63
- def required(*attributes, **options)
64
- required_attributes.concat attributes
132
+ # @return [Array<Symbol>]
133
+ def optional_failure_attributes
134
+ # Adding an optional attribute with NO predicates with Dry::Schema is
135
+ # sort of a "nothing statement" - the schema can sort of ignore it. The
136
+ # problem is that the optional-with-no-predicate key is not included in
137
+ # the #info results, so we need to find an list of keys elsewhere, find
138
+ # the ones that are listed there but not in the #info results, and find
139
+ # the difference. The result are the keys that are omitted from the #info
140
+ # result because they are optional and have no predicates.
141
+ #
142
+ # See https://github.com/dry-rb/dry-schema/issues/347
143
+ @optional_failure_attributes ||= begin
144
+ attributes_in_info = failure_schema.info[:keys].keys
145
+ all_attributes = failure_schema.key_map.keys.map(&:id)
146
+ optional_attributes_by_exclusion = all_attributes - attributes_in_info
65
147
 
66
- attributes.each do |attribute|
67
- # Define getter
68
- define_method(attribute) { @context.send(attribute) }
148
+ explicitly_optional_attributes = failure_schema.info[:keys].reject { |_, info| info[:required] }.keys
69
149
 
70
- # Define setter
71
- define_method("#{attribute}=".to_sym) do |value|
72
- @context.send("#{attribute}=".to_sym, value)
150
+ explicitly_optional_attributes + optional_attributes_by_exclusion
73
151
  end
152
+ end
74
153
 
75
- raise Interaktor::Error::UnknownOptionError.new(self.class.to_s, options) if options.any?
76
- end
154
+ # The complete list of failure attributes.
155
+ #
156
+ # @return [Array<Symbol>]
157
+ def failure_attributes
158
+ required_failure_attributes + optional_failure_attributes
77
159
  end
78
160
 
79
- # A DSL method for documenting optional interaktor attributes.
161
+ # Get the failure attribute schema. Fall back to an empty schema with a
162
+ # configuration that will deny ALL provided attributes - not defining an
163
+ # failure schema should mean the interaktor has no failure attributes.
80
164
  #
81
- # @param attributes [Symbol, Array<Symbol>] the list of attribute names
82
- # @param options [Hash]
165
+ # @return [Dry::Schema::Params]
166
+ def failure_schema
167
+ @failure_schema || Dry::Schema.Params
168
+ end
169
+
170
+ # @param context [Hash]
83
171
  #
84
172
  # @return [void]
85
- def optional(*attributes, **options)
86
- optional_attributes.concat attributes
173
+ def validate_failure_schema(context)
174
+ return unless failure_schema
87
175
 
88
- attributes.each do |attribute|
89
- # Define getter
90
- define_method(attribute) { @context.send(attribute) }
176
+ result = failure_schema.call(context)
91
177
 
92
- # Define setter
93
- define_method("#{attribute}=".to_sym) do |value|
94
- unless @context.to_h.key?(attribute)
95
- raise Interaktor::Error::DisallowedAttributeAssignmentError.new(self.class.to_s, [attribute])
96
- end
178
+ if result.errors.any?
179
+ raise Interaktor::Error::AttributeSchemaValidationError.new(
180
+ self,
181
+ result.errors.to_h,
182
+ )
183
+ end
184
+ end
97
185
 
98
- @context.send("#{attribute}=".to_sym, value)
99
- end
186
+ # @param schema [Dry::Schema::Params, nil] a predefined schema object
187
+ # @yield a new Dry::Schema::Params definition block
188
+ def failure(schema = nil, &block)
189
+ raise "No schema or schema definition block provided to interaktor failure method." if schema.nil? && !block
100
190
 
101
- # Handle options
102
- optional_defaults[attribute] = options[:default] if options[:default]
103
- options.delete(:default)
191
+ raise "Provided both a schema and a schema definition block for interaktor failure method." if schema && block
104
192
 
105
- raise Interaktor::Error::UnknownOptionError.new(self.class.to_s, options) if options.any?
193
+ if schema
194
+ raise "Provided argument is not a Dry::Schema::Params object." unless schema.is_a?(Dry::Schema::Params)
195
+
196
+ @failure_schema = schema
197
+ elsif block
198
+ @failure_schema = Dry::Schema.Params { instance_eval(&block) }
106
199
  end
107
200
  end
108
201
 
109
- # A DSL method for documenting required interaktor failure attributes.
202
+ ######################
203
+ # SUCCESS ATTRIBUTES #
204
+ ######################
205
+
206
+ # The list of attributes which are required to be provided when the
207
+ # interaktor succeeds.
110
208
  #
111
- # @param attributes [Symbol, Array<Symbol>] the list of attribute names
112
- # @param options [Hash]
209
+ # @return [Array<Symbol>]
210
+ def required_success_attributes
211
+ @required_success_attributes ||= success_schema.info[:keys]
212
+ .select { |_, info| info[:required] }
213
+ .keys
214
+ end
215
+
216
+ # The list of attributes which are not required to be provided when failing
217
+ # the interaktor.
113
218
  #
114
- # @return [void]
115
- def failure(*attributes, **options)
116
- failure_attributes.concat attributes
219
+ # @return [Array<Symbol>]
220
+ def optional_success_attributes
221
+ # Adding an optional attribute with NO predicates with Dry::Schema is
222
+ # sort of a "nothing statement" - the schema can sort of ignore it. The
223
+ # problem is that the optional-with-no-predicate key is not included in
224
+ # the #info results, so we need to find an list of keys elsewhere, find
225
+ # the ones that are listed there but not in the #info results, and find
226
+ # the difference. The result are the keys that are omitted from the #info
227
+ # result because they are optional and have no predicates.
228
+ #
229
+ # See https://github.com/dry-rb/dry-schema/issues/347
230
+ @optional_success_attributes ||= begin
231
+ attributes_in_info = success_schema.info[:keys].keys
232
+ all_attributes = success_schema.key_map.keys.map(&:id)
233
+ optional_attributes_by_exclusion = all_attributes - attributes_in_info
117
234
 
118
- attributes.each do |attribute|
119
- # Handle options
120
- raise Interaktor::Error::UnknownOptionError.new(self.class.to_s, options) if options.any?
121
- end
235
+ explicitly_optional_attributes = success_schema.info[:keys].reject { |_, info| info[:required] }.keys
236
+
237
+ explicitly_optional_attributes + optional_attributes_by_exclusion
238
+ end
122
239
  end
123
240
 
124
- # A DSL method for documenting required interaktor success attributes.
241
+ # The complete list of success attributes.
125
242
  #
126
- # @param attributes [Symbol, Array<Symbol>] the list of attribute names
127
- # @param options [Hash]
243
+ # @return [Array<Symbol>]
244
+ def success_attributes
245
+ required_success_attributes + optional_success_attributes
246
+ end
247
+
248
+ # Get the success attribute schema. Fall back to an empty schema with a
249
+ # configuration that will deny ALL provided attributes - not defining an
250
+ # success schema should mean the interaktor has no success attributes.
251
+ #
252
+ # @return [Dry::Schema::Params]
253
+ def success_schema
254
+ @success_schema || Dry::Schema.Params
255
+ end
256
+
257
+ # @param context [Hash]
128
258
  #
129
259
  # @return [void]
130
- def success(*attributes, **options)
131
- success_attributes.concat attributes
260
+ def validate_success_schema(context)
261
+ return unless success_schema
262
+
263
+ result = success_schema.call(context)
132
264
 
133
- attributes.each do |attribute|
134
- # Handle options
135
- raise Interaktor::Error::UnknownOptionError.new(self.class.to_s, options) if options.any?
265
+ if result.errors.any?
266
+ raise Interaktor::Error::AttributeSchemaValidationError.new(
267
+ self,
268
+ result.errors.to_h,
269
+ )
270
+ end
271
+ end
272
+
273
+ # @param schema [Dry::Schema::Params, nil] a predefined schema object
274
+ # @yield a new Dry::Schema::Params definition block
275
+ def success(schema = nil, &block)
276
+ raise "No schema or schema definition block provided to interaktor success method." if schema.nil? && !block
277
+
278
+ raise "Provided both a schema and a schema definition block for interaktor success method." if schema && block
279
+
280
+ if schema
281
+ raise "Provided argument is not a Dry::Schema::Params object." unless schema.is_a?(Dry::Schema::Params)
282
+
283
+ @success_schema = schema
284
+ elsif block
285
+ @success_schema = Dry::Schema.Params { instance_eval(&block) }
136
286
  end
137
287
  end
138
288
 
@@ -174,45 +324,22 @@ module Interaktor::Callable
174
324
  #
175
325
  # @return [Interaktor::Context] the context, following interaktor execution
176
326
  def execute(context, raise_exception)
177
- unless context.is_a?(Hash) || context.is_a?(Interaktor::Context)
178
- raise ArgumentError, "Expected a hash argument when calling the interaktor, got a #{context.class} instead."
179
- end
180
-
181
- apply_default_optional_attributes(context)
182
- verify_attribute_presence(context)
183
-
184
327
  run_method = raise_exception ? :run! : :run
185
328
 
186
- new(context).tap(&run_method).instance_variable_get(:@context)
187
- end
188
-
189
- # Check the provided context against the attributes defined with the DSL
190
- # methods, and determine if there are any attributes which are required and
191
- # have not been provided, or if there are any attributes which have been
192
- # provided but are not listed as either required or optional.
193
- #
194
- # @param context [Interaktor::Context] the context to check
195
- #
196
- # @return [void]
197
- def verify_attribute_presence(context)
198
- # TODO: Add "allow_nil?" option to required attributes
199
- missing_attrs = required_attributes.reject { |required_attr| context.to_h.key?(required_attr) }
200
- raise Interaktor::Error::MissingAttributeError.new(self, missing_attrs) if missing_attrs.any?
329
+ case context
330
+ when Hash
331
+ # Silently remove any attributes that are not included in the schema
332
+ allowed_keys = input_schema.key_map.keys.map { |k| k.name.to_sym }
333
+ context.select! { |k, _| allowed_keys.include?(k.to_sym) }
201
334
 
202
- allowed_attrs = required_attributes + optional_attributes
203
- extra_attrs = context.to_h.keys.reject { |attr| allowed_attrs.include?(attr) }
204
- raise Interaktor::Error::UnknownAttributeError.new(self, extra_attrs) if extra_attrs.any?
205
- end
335
+ validate_input_schema(context)
206
336
 
207
- # Given the list of optional default attribute values defined by the class,
208
- # assign those default values to the context if they were omitted.
209
- #
210
- # @param context [Interaktor::Context]
211
- #
212
- # @return [void]
213
- def apply_default_optional_attributes(context)
214
- optional_defaults.each do |attribute, default|
215
- context[attribute] ||= default
337
+ new(context).tap(&run_method).instance_variable_get(:@context)
338
+ when Interaktor::Context
339
+ new(context).tap(&run_method).instance_variable_get(:@context)
340
+ else
341
+ raise ArgumentError,
342
+ "Expected a hash argument when calling the interaktor, got a #{context.class} instead."
216
343
  end
217
344
  end
218
345
  end