operations 0.0.1 → 0.6.3

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