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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +33 -0
- data/.gitignore +4 -0
- data/.rspec +0 -2
- data/.rubocop.yml +21 -0
- data/.rubocop_todo.yml +30 -0
- data/Appraisals +8 -0
- data/CHANGELOG.md +17 -0
- data/Gemfile +8 -2
- data/README.md +910 -5
- data/Rakefile +3 -1
- data/gemfiles/rails.5.2.gemfile +14 -0
- data/gemfiles/rails.6.0.gemfile +14 -0
- data/gemfiles/rails.6.1.gemfile +14 -0
- data/gemfiles/rails.7.0.gemfile +14 -0
- data/gemfiles/rails.7.1.gemfile +14 -0
- data/lib/operations/command.rb +413 -0
- data/lib/operations/components/base.rb +79 -0
- data/lib/operations/components/callback.rb +55 -0
- data/lib/operations/components/contract.rb +20 -0
- data/lib/operations/components/idempotency.rb +70 -0
- data/lib/operations/components/on_failure.rb +16 -0
- data/lib/operations/components/on_success.rb +35 -0
- data/lib/operations/components/operation.rb +37 -0
- data/lib/operations/components/policies.rb +42 -0
- data/lib/operations/components/prechecks.rb +38 -0
- data/lib/operations/components/preconditions.rb +45 -0
- data/lib/operations/components.rb +5 -0
- data/lib/operations/configuration.rb +15 -0
- data/lib/operations/contract/messages_resolver.rb +11 -0
- data/lib/operations/contract.rb +39 -0
- data/lib/operations/convenience.rb +102 -0
- data/lib/operations/form/attribute.rb +42 -0
- data/lib/operations/form/builder.rb +85 -0
- data/lib/operations/form.rb +194 -0
- data/lib/operations/result.rb +122 -0
- data/lib/operations/test_helpers.rb +71 -0
- data/lib/operations/types.rb +6 -0
- data/lib/operations/version.rb +3 -1
- data/lib/operations.rb +42 -2
- data/operations.gemspec +20 -4
- metadata +164 -9
- data/.travis.yml +0 -6
data/Rakefile
CHANGED
@@ -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
|