operations 0.0.1 → 0.6.2

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.
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