operations 0.0.1 → 0.6.3

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 +30 -0
  7. data/Appraisals +8 -0
  8. data/CHANGELOG.md +17 -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 +413 -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,413 @@
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.optional, default: proc {}
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
+ @form_class ||= build_form_class
207
+ end
208
+
209
+ # Instantiates a new command with the given fields updated.
210
+ # Useful for defining multiple commands for a single operation body.
211
+ def merge(**changes)
212
+ self.class.new(operation, **self.class.dry_initializer.attributes(self), **changes)
213
+ end
214
+
215
+ # Executes all the components in a particular order. Returns the result
216
+ # on any step failure. First it validates the user input with the contract
217
+ # then it checks the policy and preconditions and if everything passes -
218
+ # executes the operation routine.
219
+ # The whole process always happens inside of a DB transaction.
220
+ def call(params, **context)
221
+ operation_result(unwrap_monad(call_monad(params.to_h, context)))
222
+ end
223
+
224
+ # Works the same way as `call` but raises an exception on operation failure.
225
+ def call!(params, **context)
226
+ result = call(params, **context)
227
+ raise OperationFailed.new(result) if result.failure?
228
+
229
+ result
230
+ end
231
+
232
+ # Calls the operation and raises an exception in case of a failure
233
+ # but only if preconditions and policies have passed.
234
+ # This means that the exception will be raised only on contract
235
+ # or the operation body failure.
236
+ def try_call!(params, **context)
237
+ result = call(params, **context)
238
+ raise OperationFailed.new(result) if result.failure? && !result.failed_precheck?
239
+
240
+ result
241
+ end
242
+
243
+ # Checks if the operation is valid to call in the current context and parameters.
244
+ # Performs policy preconditions and contract checks.
245
+ def validate(params, **context)
246
+ operation_result(unwrap_monad(validate_monad(params.to_h, context)))
247
+ end
248
+
249
+ # Checks if the operation is possible to call in the current context.
250
+ # Performs both: policy and preconditions checks.
251
+ def callable(params = EMPTY_HASH, **context)
252
+ operation_result(unwrap_monad(callable_monad(component(:contract).call(params.to_h, context))))
253
+ end
254
+
255
+ # Works the same way as `callable` but checks only the policy.
256
+ def allowed(params = EMPTY_HASH, **context)
257
+ operation_result(component(:policies).call(params.to_h, context))
258
+ end
259
+
260
+ # Works the same way as `callable` but checks only preconditions.
261
+ def possible(params = EMPTY_HASH, **context)
262
+ operation_result(component(:preconditions).call(params.to_h, context))
263
+ end
264
+
265
+ # These 3 methods added for convenience. They return boolean result
266
+ # instead of Operations::Result. True on success and false on failure.
267
+ %i[callable allowed possible].each do |method|
268
+ define_method :"#{method}?" do |**kwargs|
269
+ public_send(method, **kwargs).success?
270
+ end
271
+ end
272
+
273
+ # Returns boolean result instead of Operations::Result for validate method.
274
+ # True on success and false on failure.
275
+ def valid?(*args, **kwargs)
276
+ validate(*args, **kwargs).success?
277
+ end
278
+
279
+ def pretty_print(pp)
280
+ attributes = self.class.dry_initializer.attributes(self)
281
+
282
+ pp.object_group(self) do
283
+ pp.seplist(attributes.keys, -> { pp.text "," }) do |name|
284
+ pp.breakable " "
285
+ pp.group(1) do
286
+ pp.text name.to_s
287
+ pp.text " = "
288
+ pp.pp send(name)
289
+ end
290
+ end
291
+ end
292
+ end
293
+
294
+ def as_json(*)
295
+ {
296
+ **main_components_as_json,
297
+ **form_components_as_json,
298
+ configuration: configuration.as_json
299
+ }
300
+ end
301
+
302
+ private
303
+
304
+ def main_components_as_json
305
+ {
306
+ operation: operation.class.name,
307
+ contract: contract.class.name,
308
+ policies: policies.map { |policy| policy.class.name },
309
+ idempotency: idempotency.map { |idempotency_check| idempotency_check.class.name },
310
+ preconditions: preconditions.map { |precondition| precondition.class.name },
311
+ on_success: on_success.map { |on_success_component| on_success_component.class.name },
312
+ on_failure: on_failure.map { |on_failure_component| on_failure_component.class.name }
313
+ }
314
+ end
315
+
316
+ def form_components_as_json
317
+ {
318
+ form_model_map: form_model_map,
319
+ form_base: form_base.name,
320
+ form_class: form_class.name,
321
+ form_hydrator: form_hydrator.class.name
322
+ }
323
+ end
324
+
325
+ def component(identifier)
326
+ (@components ||= {})[identifier] = begin
327
+ component_kwargs = {
328
+ message_resolver: contract.message_resolver,
329
+ info_reporter: configuration.info_reporter,
330
+ error_reporter: configuration.error_reporter
331
+ }
332
+ component_kwargs[:after_commit] = configuration.after_commit if identifier == :on_success
333
+ callable = send(identifier)
334
+
335
+ "::Operations::Components::#{identifier.to_s.camelize}".constantize.new(
336
+ callable,
337
+ **component_kwargs
338
+ )
339
+ end
340
+ end
341
+
342
+ def call_monad(params, context)
343
+ operation_result = unwrap_monad(execute_operation(params, context))
344
+
345
+ return operation_result unless operation_result.component == :operation
346
+
347
+ component = operation_result.success? ? component(:on_success) : component(:on_failure)
348
+ component.call(operation_result)
349
+ end
350
+
351
+ def execute_operation(params, context)
352
+ configuration.transaction.call do
353
+ contract_result = yield validate_monad(params, context, call_idempotency: true)
354
+
355
+ yield component(:operation).call(contract_result.params, contract_result.context)
356
+ end
357
+ end
358
+
359
+ def validate_monad(params, context, call_idempotency: false)
360
+ contract_result = component(:contract).call(params, context)
361
+
362
+ yield callable_monad(contract_result, call_idempotency: call_idempotency)
363
+
364
+ contract_result
365
+ end
366
+
367
+ def callable_monad(contract_result, call_idempotency: false)
368
+ # We need to check policies/preconditions at the beginning.
369
+ # But since contract loads entities, we need to run it first.
370
+ yield contract_result if contract_result.failure? && !component(:policies).callable?(contract_result.context)
371
+ yield component(:policies).call(contract_result.params, contract_result.context)
372
+
373
+ if call_idempotency
374
+ idempotency_result = yield component(:idempotency)
375
+ .call(contract_result.params, contract_result.context)
376
+ end
377
+
378
+ yield contract_result if contract_result.failure? && !component(:preconditions).callable?(contract_result.context)
379
+ preconditions_result = yield component(:preconditions).call(contract_result.params, contract_result.context)
380
+
381
+ idempotency_result || preconditions_result
382
+ end
383
+
384
+ def operation_result(result)
385
+ result.merge(operation: self)
386
+ end
387
+
388
+ def unwrap_monad(result)
389
+ case result
390
+ when Success
391
+ result.value!
392
+ when Failure
393
+ result.failure
394
+ else
395
+ result
396
+ end
397
+ end
398
+
399
+ def build_form_class
400
+ ::Operations::Form::Builder
401
+ .new(base_class: form_base)
402
+ .build(
403
+ key_map: contract.class.schema.key_map,
404
+ namespace: operation.class,
405
+ class_name: form_class_name,
406
+ model_map: form_model_map
407
+ )
408
+ end
409
+
410
+ def form_class_name
411
+ "#{contract.class.name.demodulize.delete_suffix("Contract")}Form" if contract.class.name
412
+ end
413
+ 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