operations 0.0.1 → 0.6.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +33 -0
  3. data/.gitignore +4 -0
  4. data/.rspec +0 -2
  5. data/.rubocop.yml +21 -0
  6. data/.rubocop_todo.yml +36 -0
  7. data/Appraisals +8 -0
  8. data/CHANGELOG.md +11 -0
  9. data/Gemfile +8 -2
  10. data/README.md +910 -5
  11. data/Rakefile +3 -1
  12. data/gemfiles/rails.5.2.gemfile +14 -0
  13. data/gemfiles/rails.6.0.gemfile +14 -0
  14. data/gemfiles/rails.6.1.gemfile +14 -0
  15. data/gemfiles/rails.7.0.gemfile +14 -0
  16. data/gemfiles/rails.7.1.gemfile +14 -0
  17. data/lib/operations/command.rb +412 -0
  18. data/lib/operations/components/base.rb +79 -0
  19. data/lib/operations/components/callback.rb +55 -0
  20. data/lib/operations/components/contract.rb +20 -0
  21. data/lib/operations/components/idempotency.rb +70 -0
  22. data/lib/operations/components/on_failure.rb +16 -0
  23. data/lib/operations/components/on_success.rb +35 -0
  24. data/lib/operations/components/operation.rb +37 -0
  25. data/lib/operations/components/policies.rb +42 -0
  26. data/lib/operations/components/prechecks.rb +38 -0
  27. data/lib/operations/components/preconditions.rb +45 -0
  28. data/lib/operations/components.rb +5 -0
  29. data/lib/operations/configuration.rb +15 -0
  30. data/lib/operations/contract/messages_resolver.rb +11 -0
  31. data/lib/operations/contract.rb +39 -0
  32. data/lib/operations/convenience.rb +102 -0
  33. data/lib/operations/form/attribute.rb +42 -0
  34. data/lib/operations/form/builder.rb +85 -0
  35. data/lib/operations/form.rb +194 -0
  36. data/lib/operations/result.rb +122 -0
  37. data/lib/operations/test_helpers.rb +71 -0
  38. data/lib/operations/types.rb +6 -0
  39. data/lib/operations/version.rb +3 -1
  40. data/lib/operations.rb +42 -2
  41. data/operations.gemspec +20 -4
  42. metadata +164 -9
  43. data/.travis.yml +0 -6
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bundler/gem_tasks"
2
4
  require "rspec/core/rake_task"
3
5
 
4
6
  RSpec::Core::RakeTask.new(:spec)
5
7
 
6
- task :default => :spec
8
+ task default: :spec
@@ -0,0 +1,14 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "bookingsync-rubocop", require: false, github: "BookingSync/bookingsync-rubocop", branch: "main"
6
+ gem "rspec"
7
+ gem "rubocop", require: false
8
+ gem "rubocop-performance", require: false
9
+ gem "rubocop-rails", require: false
10
+ gem "rubocop-rspec", require: false
11
+ gem "activerecord", "~> 5.2.0"
12
+ gem "activesupport", "~> 5.2.0"
13
+
14
+ gemspec path: "../"
@@ -0,0 +1,14 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "bookingsync-rubocop", require: false, github: "BookingSync/bookingsync-rubocop", branch: "main"
6
+ gem "rspec"
7
+ gem "rubocop", require: false
8
+ gem "rubocop-performance", require: false
9
+ gem "rubocop-rails", require: false
10
+ gem "rubocop-rspec", require: false
11
+ gem "activerecord", "~> 6.0.0"
12
+ gem "activesupport", "~> 6.0.0"
13
+
14
+ gemspec path: "../"
@@ -0,0 +1,14 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "bookingsync-rubocop", require: false, github: "BookingSync/bookingsync-rubocop", branch: "main"
6
+ gem "rspec"
7
+ gem "rubocop", require: false
8
+ gem "rubocop-performance", require: false
9
+ gem "rubocop-rails", require: false
10
+ gem "rubocop-rspec", require: false
11
+ gem "activerecord", "~> 6.1.0"
12
+ gem "activesupport", "~> 6.1.0"
13
+
14
+ gemspec path: "../"
@@ -0,0 +1,14 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "bookingsync-rubocop", require: false, github: "BookingSync/bookingsync-rubocop", branch: "main"
6
+ gem "rspec"
7
+ gem "rubocop", require: false
8
+ gem "rubocop-performance", require: false
9
+ gem "rubocop-rails", require: false
10
+ gem "rubocop-rspec", require: false
11
+ gem "activerecord", "~> 7.0.0"
12
+ gem "activesupport", "~> 7.0.0"
13
+
14
+ gemspec path: "../"
@@ -0,0 +1,14 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "bookingsync-rubocop", require: false, github: "BookingSync/bookingsync-rubocop", branch: "main"
6
+ gem "rspec"
7
+ gem "rubocop", require: false
8
+ gem "rubocop-performance", require: false
9
+ gem "rubocop-rails", require: false
10
+ gem "rubocop-rspec", require: false
11
+ gem "activerecord", "~> 7.1.0"
12
+ gem "activesupport", "~> 7.1.0"
13
+
14
+ gemspec path: "../"
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components"
4
+ require "operations/components/contract"
5
+ require "operations/components/policies"
6
+ require "operations/components/preconditions"
7
+ require "operations/components/idempotency"
8
+ require "operations/components/operation"
9
+ require "operations/components/on_success"
10
+ require "operations/components/on_failure"
11
+ # This is an entry point interface for every operation in
12
+ # the operations layer. Every operation instance consists of 4
13
+ # components: contract, policy, preconditions and operation
14
+ # routine. Each component is a class that implements `call`
15
+ # instance method.
16
+ #
17
+ # @example
18
+ #
19
+ # repo = SomeRepo.new
20
+ #
21
+ # operation = Operations::Command.new(
22
+ # OperationClass.new(repo: repo),
23
+ # contract: ContractClass.new(repo: repo),
24
+ # policies: PolicyClass.new,
25
+ # preconditions: PreconditionClass.new
26
+ # )
27
+ #
28
+ # operation.call(params, context)
29
+ # operation.callable(context)
30
+ #
31
+ # Operation has an application lifetime. This means that the
32
+ # instance is created on the application start-up and supposed
33
+ # to be completely stateless. Each component also supposed
34
+ # to be stateless with the dependencies (like repositories or
35
+ # API clients passed on initialization).
36
+ #
37
+ # Since operations have an application lifetime, they have to be
38
+ # easily accessible from somewhere. The most perfect place for storing
39
+ # them (as for a lot of other concepts like repositories) would be
40
+ # an application container. But until we introduced it - operation
41
+ # can be memoized in the operation's class method.
42
+ #
43
+ # @example
44
+ #
45
+ # class Namespace::OperationName
46
+ # def self.default
47
+ # @default ||= Operations::Command.new(new, ...)
48
+ # end
49
+ # end
50
+ #
51
+ # Namespace::OperationName.default.call(params, ...)
52
+ #
53
+ # The main 2 entry point methods are: {#call} and {#callable}.
54
+ # The first one will perform the whole routime and the second
55
+ # one will check if it is possible to perform the routine at
56
+ # this moment since policy or preconditions can prevent it.
57
+ #
58
+ # Each of the methods accepts 2 arguments: params and context:
59
+ #
60
+ # 1. Params is purely a user input, which is passed to the contract
61
+ # coercion and validation.
62
+ # 2. Context has 2 roles: it holds the initial context like `current_user`
63
+ # or anything that can't be received from the user but is required
64
+ # by the operation. Also, it can be enriched by the contract later.
65
+ #
66
+ # When we check {#callable}, params can be ommited since we don't
67
+ # have them at the moment and they will not affect the returned value.
68
+ # Put it is still possible to pass them if they are required by some
69
+ # reason.
70
+ #
71
+ # Now, components. The `call` functions of each component are
72
+ # executed in a particular order. Each component has its purpose:
73
+ #
74
+ # 1. Contract (which is a standard {Dry::Validation::Contract})
75
+ # has a responsibility of validating the user input, coercing
76
+ # the values and enriching the initial context by e.g. loading
77
+ # entities from the DB. After the contract evaluation, there
78
+ # should be everything in the context that is required for the
79
+ # rest of the steps. This happens in the contract's rules.
80
+ # Contract returns a standard {Dry::Validation::Result}.
81
+ # See {https://dry-rb.org/gems/dry-validation/1.5/} for details.
82
+ # 2. Policy checks if the operation is allowed for execution. Mostly by
83
+ # the current user but there might be other options. The policy
84
+ # retuns a boolean result. Allowed or not. Policy relies mostly on
85
+ # the initial context but can also use the results of the Contract
86
+ # rules evaluation.
87
+ # 3. Idempotency check are running after policy and before preconditions and
88
+ # can return either Success() or Failure({}). In case of Failure, preconditions,
89
+ # the operation body (and after calls) will be skept but the operation
90
+ # result will be successful. Failure({}) can carry an additional context
91
+ # to make sure the operation result context is going to be the same for both
92
+ # cases of normal operation execution and skipped operation body. The
93
+ # only sign of the execution interrupted at this stage will be the
94
+ # value of {Result#component} equal to `:idempotency`.
95
+ # 4. Precondition is checking if the operation is possible to
96
+ # perform for the current domain state. For example, if
97
+ # {Booking::Cancel} is possible to execute for a particular booking.
98
+ # There might be multiple checks, so precondition returns either
99
+ # a Symbol code designating the particular check failure or `nil`
100
+ # which means the checks have passed. Like Policy it heavily relies
101
+ # on the context (either initial or the data loaded by the contract).
102
+ # Anything that has nothing to do with the user input validation
103
+ # should be implemented as a precondition.
104
+ # 5. Operation itself implements the routine. It can create or update
105
+ # enities, send API requiests, send notifications, communicate with
106
+ # the bus. Anything that should be done as a part of the operation.
107
+ # Operation returns a Result monad (either Success or Failure).
108
+ # See {https://dry-rb.org/gems/dry-monads/1.3/} for details. Also,
109
+ # it is better to use Do notation for the implementation. If Success
110
+ # result contains a hash, it is returned as a part of the context.
111
+ # 6. `on_success` calls run after the operation was successful and transaction
112
+ # was committed. Composite adds the result of the `on_success` calls to the
113
+ # operation result but in case of failed `on_success` calls, the
114
+ # operation is still marked as successful. Each particular `on_success`
115
+ # entry is wrapped inside of a dedicated DB transaction.
116
+ # Given this, avoid putting business logic here, only something
117
+ # that can be replayed. Each callable object is expected to have the
118
+ # same method's signature as operation's `call` method.
119
+ # 7. `on_failure` calls run after the operation failed and transaction
120
+ # was rolled back. Composite adds the result of the `on_failure` calls to the
121
+ # operation result. Each particular `on_failure`
122
+ # entry is wrapped inside of a dedicated DB transaction.
123
+ #
124
+ # Every method in {Operations::Command} returns {Operations::Result} instance,
125
+ # which contains all the artifacts and the information about the errors
126
+ # should they ever happen.
127
+ class Operations::Command
128
+ UNDEFINED = Object.new.freeze
129
+ EMPTY_HASH = {}.freeze
130
+ COMPONENTS = %i[contract policies idempotency preconditions operation on_success on_failure].freeze
131
+ FORM_HYDRATOR = ->(_form_class, params, **_context) { params }
132
+
133
+ include Dry::Monads[:result]
134
+ include Dry::Monads::Do.for(:call_monad, :callable_monad, :validate_monad, :execute_operation)
135
+ include Dry::Equalizer(*COMPONENTS)
136
+ extend Dry::Initializer
137
+
138
+ # Provides message and meaningful sentry context for failed operations
139
+ class OperationFailed < StandardError
140
+ attr_reader :operation_result
141
+
142
+ def initialize(operation_result)
143
+ @operation_result = operation_result
144
+ operation_class_name = operation_result.operation&.operation&.class&.name
145
+
146
+ super("#{operation_class_name} failed on #{operation_result.component}")
147
+ end
148
+
149
+ def sentry_context
150
+ operation_result.as_json(include_command: true)
151
+ end
152
+ end
153
+
154
+ param :operation, Operations::Types.Interface(:call)
155
+ option :contract, Operations::Types.Interface(:call)
156
+ option :policies, Operations::Types::Array.of(Operations::Types.Interface(:call))
157
+ option :idempotency, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
158
+ option :preconditions, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
159
+ option :on_success, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
160
+ option :on_failure, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
161
+ option :form_model_map, Operations::Types::Hash.map(
162
+ Operations::Types::Coercible::Array.of(
163
+ Operations::Types::String | Operations::Types::Symbol | Operations::Types.Instance(Regexp)
164
+ ),
165
+ Operations::Types::String
166
+ ), default: proc { {} }
167
+ option :form_base, Operations::Types::Class, default: proc { ::Operations::Form }
168
+ option :form_class, Operations::Types::Class, default: proc { build_form }
169
+ option :form_hydrator, Operations::Types.Interface(:call), default: proc { FORM_HYDRATOR }
170
+ option :configuration, Operations::Configuration, default: proc { Operations.default_config }
171
+
172
+ # A short-cut to initialize operation by convention:
173
+ #
174
+ # Namespace::OperationName - operation
175
+ # Namespace::OperationName::Contract - contract
176
+ # Namespace::OperationName::Policies - policies
177
+ # Namespace::OperationName::Preconditions - preconditions
178
+ #
179
+ # All the dependencies are passed to every component's
180
+ # initializer, so they'd be better tolerant to unknown
181
+ # dependencies. Luckily it is easily achievable with {Dry::Initializer}.
182
+ # This plays really well with {Operations::Convenience}
183
+ #
184
+ # @see {https://dry-rb.org/gems/dry-initializer/3.0/ for details}
185
+ # @see Operations::Convenience
186
+ def self.build(operation, contract = nil, **deps)
187
+ options = {
188
+ contract: (contract || operation::Contract).new(**deps),
189
+ policies: [operation::Policy.new(**deps)]
190
+ }
191
+ options[:preconditions] = [operation::Precondition.new(**deps)] if operation.const_defined?(:Precondition)
192
+
193
+ new(operation.new(**deps), **options)
194
+ end
195
+
196
+ def initialize(
197
+ operation, policy: UNDEFINED, policies: [UNDEFINED],
198
+ precondition: nil, preconditions: [], after: [], **options
199
+ )
200
+ policies_sum = Array.wrap(policy) + policies
201
+ result_policies = policies_sum - [UNDEFINED] unless policies_sum == [UNDEFINED, UNDEFINED]
202
+ options[:policies] = result_policies if result_policies
203
+
204
+ preconditions.push(precondition) if precondition.present?
205
+ super(operation, preconditions: preconditions, on_success: after, **options)
206
+ end
207
+
208
+ # Instantiates a new command with the given fields updated.
209
+ # Useful for defining multiple commands for a single operation body.
210
+ def merge(**changes)
211
+ self.class.new(operation, **self.class.dry_initializer.attributes(self), **changes)
212
+ end
213
+
214
+ # Executes all the components in a particular order. Returns the result
215
+ # on any step failure. First it validates the user input with the contract
216
+ # then it checks the policy and preconditions and if everything passes -
217
+ # executes the operation routine.
218
+ # The whole process always happens inside of a DB transaction.
219
+ def call(params, **context)
220
+ operation_result(unwrap_monad(call_monad(params.to_h, context)))
221
+ end
222
+
223
+ # Works the same way as `call` but raises an exception on operation failure.
224
+ def call!(params, **context)
225
+ result = call(params, **context)
226
+ raise OperationFailed.new(result) if result.failure?
227
+
228
+ result
229
+ end
230
+
231
+ # Calls the operation and raises an exception in case of a failure
232
+ # but only if preconditions and policies have passed.
233
+ # This means that the exception will be raised only on contract
234
+ # or the operation body failure.
235
+ def try_call!(params, **context)
236
+ result = call(params, **context)
237
+ raise OperationFailed.new(result) if result.failure? && !result.failed_precheck?
238
+
239
+ result
240
+ end
241
+
242
+ # Checks if the operation is valid to call in the current context and parameters.
243
+ # Performs policy preconditions and contract checks.
244
+ def validate(params, **context)
245
+ operation_result(unwrap_monad(validate_monad(params.to_h, context)))
246
+ end
247
+
248
+ # Checks if the operation is possible to call in the current context.
249
+ # Performs both: policy and preconditions checks.
250
+ def callable(params = EMPTY_HASH, **context)
251
+ operation_result(unwrap_monad(callable_monad(component(:contract).call(params.to_h, context))))
252
+ end
253
+
254
+ # Works the same way as `callable` but checks only the policy.
255
+ def allowed(params = EMPTY_HASH, **context)
256
+ operation_result(component(:policies).call(params.to_h, context))
257
+ end
258
+
259
+ # Works the same way as `callable` but checks only preconditions.
260
+ def possible(params = EMPTY_HASH, **context)
261
+ operation_result(component(:preconditions).call(params.to_h, context))
262
+ end
263
+
264
+ # These 3 methods added for convenience. They return boolean result
265
+ # instead of Operations::Result. True on success and false on failure.
266
+ %i[callable allowed possible].each do |method|
267
+ define_method "#{method}?" do |**kwargs|
268
+ public_send(method, **kwargs).success?
269
+ end
270
+ end
271
+
272
+ # Returns boolean result instead of Operations::Result for validate method.
273
+ # True on success and false on failure.
274
+ def valid?(*args, **kwargs)
275
+ validate(*args, **kwargs).success?
276
+ end
277
+
278
+ def pretty_print(pp)
279
+ attributes = self.class.dry_initializer.attributes(self)
280
+
281
+ pp.object_group(self) do
282
+ pp.seplist(attributes.keys, -> { pp.text "," }) do |name|
283
+ pp.breakable " "
284
+ pp.group(1) do
285
+ pp.text name.to_s
286
+ pp.text " = "
287
+ pp.pp send(name)
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ def as_json(*)
294
+ {
295
+ **main_components_as_json,
296
+ **form_components_as_json,
297
+ configuration: configuration.as_json
298
+ }
299
+ end
300
+
301
+ private
302
+
303
+ def main_components_as_json
304
+ {
305
+ operation: operation.class.name,
306
+ contract: contract.class.name,
307
+ policies: policies.map { |policy| policy.class.name },
308
+ idempotency: idempotency.map { |idempotency_check| idempotency_check.class.name },
309
+ preconditions: preconditions.map { |precondition| precondition.class.name },
310
+ on_success: on_success.map { |on_success_component| on_success_component.class.name },
311
+ on_failure: on_failure.map { |on_failure_component| on_failure_component.class.name }
312
+ }
313
+ end
314
+
315
+ def form_components_as_json
316
+ {
317
+ form_model_map: form_model_map,
318
+ form_base: form_base.name,
319
+ form_class: form_class.name,
320
+ form_hydrator: form_hydrator.class.name
321
+ }
322
+ end
323
+
324
+ def component(identifier)
325
+ (@components ||= {})[identifier] = begin
326
+ component_kwargs = {
327
+ message_resolver: contract.message_resolver,
328
+ info_reporter: configuration.info_reporter,
329
+ error_reporter: configuration.error_reporter
330
+ }
331
+ component_kwargs[:after_commit] = configuration.after_commit if identifier == :on_success
332
+ callable = send(identifier)
333
+
334
+ "::Operations::Components::#{identifier.to_s.camelize}".constantize.new(
335
+ callable,
336
+ **component_kwargs
337
+ )
338
+ end
339
+ end
340
+
341
+ def call_monad(params, context)
342
+ operation_result = unwrap_monad(execute_operation(params, context))
343
+
344
+ return operation_result unless operation_result.component == :operation
345
+
346
+ component = operation_result.success? ? component(:on_success) : component(:on_failure)
347
+ component.call(operation_result)
348
+ end
349
+
350
+ def execute_operation(params, context)
351
+ configuration.transaction.call do
352
+ contract_result = yield validate_monad(params, context, call_idempotency: true)
353
+
354
+ yield component(:operation).call(contract_result.params, contract_result.context)
355
+ end
356
+ end
357
+
358
+ def validate_monad(params, context, call_idempotency: false)
359
+ contract_result = component(:contract).call(params, context)
360
+
361
+ yield callable_monad(contract_result, call_idempotency: call_idempotency)
362
+
363
+ contract_result
364
+ end
365
+
366
+ def callable_monad(contract_result, call_idempotency: false)
367
+ # We need to check policies/preconditions at the beginning.
368
+ # But since contract loads entities, we need to run it first.
369
+ yield contract_result if contract_result.failure? && !component(:policies).callable?(contract_result.context)
370
+ yield component(:policies).call(contract_result.params, contract_result.context)
371
+
372
+ if call_idempotency
373
+ idempotency_result = yield component(:idempotency)
374
+ .call(contract_result.params, contract_result.context)
375
+ end
376
+
377
+ yield contract_result if contract_result.failure? && !component(:preconditions).callable?(contract_result.context)
378
+ preconditions_result = yield component(:preconditions).call(contract_result.params, contract_result.context)
379
+
380
+ idempotency_result || preconditions_result
381
+ end
382
+
383
+ def operation_result(result)
384
+ result.merge(operation: self)
385
+ end
386
+
387
+ def unwrap_monad(result)
388
+ case result
389
+ when Success
390
+ result.value!
391
+ when Failure
392
+ result.failure
393
+ else
394
+ result
395
+ end
396
+ end
397
+
398
+ def build_form
399
+ ::Operations::Form::Builder
400
+ .new(base_class: form_base)
401
+ .build(
402
+ key_map: contract.class.schema.key_map,
403
+ namespace: operation.class,
404
+ class_name: form_class_name,
405
+ model_map: form_model_map
406
+ )
407
+ end
408
+
409
+ def form_class_name
410
+ "#{contract.class.name.demodulize.delete_suffix("Contract")}Form" if contract.class.name
411
+ end
412
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An ancestor for all the operation components.
4
+ # Holds shared methods.
5
+ class Operations::Components::Base
6
+ include Dry::Monads[:result]
7
+ extend Dry::Initializer
8
+
9
+ MONADS_DO_WRAPPER_SIGNATURES = [
10
+ [%i[rest *], %i[block &]],
11
+ [%i[rest], %i[block &]], # Ruby 3.0, 3.1
12
+ [%i[rest *], %i[keyrest **], %i[block &]],
13
+ [%i[rest], %i[keyrest], %i[block &]] # Ruby 3.0, 3.1
14
+ ].freeze
15
+ DEFAULT_NAMES_MAP = { # Ruby 3.0, 3.1
16
+ rest: "*",
17
+ keyrest: "**"
18
+ }.freeze
19
+
20
+ param :callable, type: Operations::Types.Interface(:call)
21
+ option :message_resolver, type: Operations::Types.Interface(:call), optional: true
22
+ option :info_reporter, type: Operations::Types::Nil | Operations::Types.Interface(:call), optional: true
23
+ option :error_reporter, type: Operations::Types::Nil | Operations::Types.Interface(:call), optional: true
24
+
25
+ private
26
+
27
+ def result(**options)
28
+ ::Operations::Result.new(
29
+ component: self.class.name.demodulize.underscore.to_sym,
30
+ **options
31
+ )
32
+ end
33
+
34
+ def call_args(callable, types:)
35
+ (@call_args ||= {})[[callable, types]] ||= call_method(callable).parameters.filter_map do |(type, name)|
36
+ name || DEFAULT_NAMES_MAP[type] if types.include?(type)
37
+ end
38
+ end
39
+
40
+ def call_method(callable)
41
+ method = callable.respond_to?(:parameters) ? callable : callable.method(:call)
42
+ # calling super_method here because `Operations::Convenience`
43
+ # calls `include Dry::Monads::Do.for(:call)` which creates
44
+ # a delegator method around the original one.
45
+ method = method.super_method if MONADS_DO_WRAPPER_SIGNATURES.include?(method.parameters)
46
+ method
47
+ end
48
+
49
+ def errors(data)
50
+ messages = Array.wrap(data).map do |datum|
51
+ message_resolver.call(
52
+ message: datum[:message],
53
+ path: Array.wrap(datum[:path] || [nil]),
54
+ tokens: datum[:tokens] || {},
55
+ meta: datum[:meta] || {}
56
+ )
57
+ end
58
+
59
+ Dry::Validation::MessageSet.new(messages).freeze
60
+ end
61
+
62
+ def normalize_failure(failure)
63
+ case failure
64
+ when Array
65
+ failure.map { |f| normalize_failure(f) }
66
+ when Hash
67
+ {
68
+ message: failure[:message] || failure[:error],
69
+ tokens: failure[:tokens],
70
+ path: failure[:path],
71
+ meta: failure.except(:message, :error, :tokens, :path)
72
+ }
73
+ when String, Symbol
74
+ { message: failure }
75
+ else
76
+ raise "Unexpected failure contents: #{failure}"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components/base"
4
+
5
+ # This base component handles `on_failure:` and `on_success:` callbacks
6
+ # passed to the command. Every callback entry is called outside of the
7
+ # operation transaction and any exception is rescued here so the result
8
+ # of the whole operation is not affected. Additionally, any callback
9
+ # failures will be reported with the command error reporter.
10
+ # The original operation result will be optionally passed as the second
11
+ # positional argument for the `call` method.
12
+ class Operations::Components::Callback < Operations::Components::Base
13
+ include Dry::Monads::Do.for(:call_entry)
14
+
15
+ param :callable, type: Operations::Types::Array.of(Operations::Types.Interface(:call))
16
+
17
+ private
18
+
19
+ def call_entry(entry, operation_result, **context)
20
+ result = yield(entry_result(entry, operation_result, **context))
21
+
22
+ Success(result)
23
+ rescue Dry::Monads::Do::Halt => e
24
+ e.result
25
+ rescue => e
26
+ Failure(e)
27
+ end
28
+
29
+ def entry_result(entry, operation_result, **context)
30
+ args = call_args(entry, types: %i[req opt])
31
+ kwargs = call_args(entry, types: %i[key keyreq keyrest])
32
+
33
+ case [args.size, kwargs.present?]
34
+ when [1, true]
35
+ entry.call(operation_result.params, **context)
36
+ when [1, false]
37
+ entry.call(operation_result)
38
+ when [0, true]
39
+ entry.call(**context)
40
+ else
41
+ raise "Invalid callback `#call` signature. Should be either `(params, **context)` or `(operation_result)`"
42
+ end
43
+ end
44
+
45
+ def maybe_report_failure(callback_type, result)
46
+ if result.public_send(callback_type).any?(Failure)
47
+ error_reporter&.call(
48
+ "Operation #{callback_type} side-effects went sideways",
49
+ result: result.as_json(include_command: true)
50
+ )
51
+ end
52
+
53
+ result
54
+ end
55
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components/base"
4
+
5
+ # Wraps contract call to adapt the result for further processing.
6
+ class Operations::Components::Contract < Operations::Components::Base
7
+ def call(params, context)
8
+ contract_result = callable.call(params, **context)
9
+
10
+ result(
11
+ params: contract_result.to_h,
12
+ context: contract_result.context.each.to_h,
13
+ # This is the only smart way I figured out to pass options
14
+ # to the schema error messages. The machinery is buried too
15
+ # deeply in dry-schema so reproducing it or trying to use
16
+ # some private API would be too fragile.
17
+ errors: ->(**options) { contract_result.errors(**options) }
18
+ )
19
+ end
20
+ end