operations 0.0.1 → 0.6.2
Sign up to get free protection for your applications and to get access to all the features.
- 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 +36 -0
- data/Appraisals +8 -0
- data/CHANGELOG.md +11 -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 +412 -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/README.md
CHANGED
@@ -1,8 +1,49 @@
|
|
1
1
|
# Operations
|
2
2
|
|
3
|
-
|
3
|
+
## A bit of theory
|
4
4
|
|
5
|
-
|
5
|
+
### What is an operation
|
6
|
+
|
7
|
+
First of all, let's define an application as a combination of domain logic and application state. Domain logic can either read and return the parts of the application state to the consumer (Query) or can modify the application state (Command).
|
8
|
+
|
9
|
+
**Note:** There is a concept that is called Command Query Separation (CQS) or Command Query Responsibility Segregation (CQRS) and which can be used at any level of the implementation (classes in OOP, API) but the general idea is simply not to mix these two up.
|
10
|
+
|
11
|
+
While Query is a simple concept (just fetch data from the application state and render it to the consumer in the requested form), Command implementation can have a lot of caveats.
|
12
|
+
|
13
|
+
_Command_ or _business operation_ or _interaction_ or _use case_ or _application service_ (DDD term) even just _service_ (this term is so ambiguous) is a predefined sequence of programmed actions that can be called by a user (directly in the code or via the API) and modifies the application state. In the scope of this framework, we prefer the _operation_ term though.
|
14
|
+
|
15
|
+
These modifications are atomic in the sense that the application state is supposed to be consistent and valid after the operation execution. It might be eventually consistent but the idea is that the application state is valid after the operation execution and in a normal case, there should be no need to call a complimentary operation to make the state valid.
|
16
|
+
|
17
|
+
Operations can also create different side effects such as: sending an email message, making asynchronous API calls (shoot-and-forget), pushing events to an event bus, etc.
|
18
|
+
|
19
|
+
**Note:** An important note is that contrary to the pure DDD approach that considers aggregate a transactional boundary, in this framework - the operation itself is wrapped inside of a transaction, though it is configurable.
|
20
|
+
|
21
|
+
The bottom line here is: any modifications to the application state, whether it is done via controller or Sidekiq job, or even console should happen in operations. Operation is the only entry point to the domain modification.
|
22
|
+
|
23
|
+
### The Rails way
|
24
|
+
|
25
|
+
In a classic Rails application, the role of business operations is usually played by ActiveRecord models. When a single model implements multiple use cases, it creates messy noodles of code that are trying to incorporate all the possible paths of execution. This leads to a lot of not funny things including conditional callbacks and virtual attributes on models. Simply put, this way violates the SRP principle and the consequences are well known.
|
26
|
+
|
27
|
+
Each operation in turn contains a single execution routine, a single sequence of program calls that is easy to read and modify.
|
28
|
+
|
29
|
+
This approach might look more fragile in the sense that ActiveRecord big ball of mud that might look like centralized logic storage and if we will not use it, we might miss important parts of domain logic and produce an invalid application state (e.g. after each update an associated record in the DB supposed to be updated somehow). This might be the case indeed but it can be easily solved by using a tiny bit of determination. The benefits of the operations approach easily overweigh this potential issue.
|
30
|
+
|
31
|
+
### Operations prerequisites
|
32
|
+
|
33
|
+
Operations are supposed to be the first-class citizens of an application. This means that ideally application state is supposed to be modified using operations exclusively. There are some exceptions though:
|
34
|
+
|
35
|
+
* In tests, sometimes it is faster and simpler to create an application state using direct storage calls (factories) since the desired state might be a result of multiple operations' calls which can be omitted for the sake of performance.
|
36
|
+
* When the running application state is inconsistent or invalid, there might not be an appropriate operation implemented to fix the state. So we have to use direct storage modifications.
|
37
|
+
|
38
|
+
**NOTE:** When application state is valid but an appropriate operation does not exist, it is better to create one. Especially if the requested state modification needs to happen more than 1 time.
|
39
|
+
|
40
|
+
### Alternatives
|
41
|
+
|
42
|
+
There are many alternatives to this framework in the Rails world such as:
|
43
|
+
|
44
|
+
* https://github.com/AaronLasseigne/active_interaction
|
45
|
+
* https://github.com/toptal/granite
|
46
|
+
* https://github.com/trailblazer/trailblazer
|
6
47
|
|
7
48
|
## Installation
|
8
49
|
|
@@ -22,7 +63,872 @@ Or install it yourself as:
|
|
22
63
|
|
23
64
|
## Usage
|
24
65
|
|
25
|
-
|
66
|
+
### Getting started
|
67
|
+
|
68
|
+
The simplest operation that is implemented with the framework will look like this:
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
create_user = Operations::Command.new(
|
72
|
+
User::Create.new,
|
73
|
+
contract: User::CreateContract.new,
|
74
|
+
policy: nil
|
75
|
+
)
|
76
|
+
|
77
|
+
create_user.call({ email: "user@gmail.com" })
|
78
|
+
```
|
79
|
+
|
80
|
+
Where the operation body is implemented as:
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
class User::Create
|
84
|
+
include Dry::Monads[:result]
|
85
|
+
|
86
|
+
def call(params, **)
|
87
|
+
user = User.new(params)
|
88
|
+
if user.save
|
89
|
+
Success(user: user)
|
90
|
+
else
|
91
|
+
Failure(:user_not_created)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
```
|
96
|
+
|
97
|
+
And the contract as:
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
class User::CreateContract < Operations::Contract
|
101
|
+
params do
|
102
|
+
required(:email).filled(:string, format?: URI::MailTo::EMAIL_REGEXP)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
```
|
106
|
+
|
107
|
+
Where `Operations::Contract` is actually a [Dry::Validation::Contract](https://dry-rb.org/gems/dry-validation/) with tiny additions.
|
108
|
+
|
109
|
+
Everything in the framework is built with the composition over inheritance approach in mind. An instance of `Operations::Command` essentially runs a pipeline through the steps passed into the initializer. In this particular case, the passed parameters will be validated by the contract and if everything is good, will be passed into the operation body.
|
110
|
+
|
111
|
+
**Important:** the whole operation pipeline (except [callbacks](#callbacks-on-success-on-failure)) is wrapped within a transaction by default. This behavior can be adjusted by changing `Operations::Configuration#transaction` (see [Configuration](#configuration) section).
|
112
|
+
|
113
|
+
### Operation Result
|
114
|
+
|
115
|
+
A result of any operation call is an instance of `Operation::Result` which contains all the necessary information:
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
create_user.call({ email: "user@gmail.com" }) #=>
|
119
|
+
# #<Operations::Result ...>,
|
120
|
+
# component = :operation,
|
121
|
+
# params = {:email=>"user@gmail.com"},
|
122
|
+
# context = {:user=>#<User ...>},
|
123
|
+
# on_success = [],
|
124
|
+
# on_failure = [],
|
125
|
+
# errors = #<Dry::Validation::MessageSet messages=[] options={}>>
|
126
|
+
```
|
127
|
+
|
128
|
+
* `component` - the stage where execution stopped. If it operation failed on precondition it is going to be `:preconditions`. See `Operations::Command::COMPONENTS` for the full list.
|
129
|
+
* `params` - params passed to the operation `call` and other methods.
|
130
|
+
* `context` - initial context merged with the context generated by operation body returned in Success() monad.
|
131
|
+
* `on_success`, `on_failure` - corresponding callback results.
|
132
|
+
* `errors` - a list of errors returned by contract/policies/preconditions.
|
133
|
+
|
134
|
+
There are several useful methods on `Operations::Result`:
|
135
|
+
|
136
|
+
* `success?`, `failure?` - checks for errors presence.
|
137
|
+
* `errors(full: true)` - this is not only an attribute but also a method accepting additional params just like `Dry::Validation::Contract#errors` does.
|
138
|
+
* `failed_policy?`, `failed_precondition?`, `failed_precheck?` - checks whether the operation failed on policy, precondition, or any of them respectively. It is possible to check for exact error codes as well.
|
139
|
+
|
140
|
+
### Params and context
|
141
|
+
|
142
|
+
Every operation input consists of 2 components: params and context. Params is a hash and it is passed as a hash argument while context is passed as kwargs.
|
143
|
+
|
144
|
+
Params are used to pass user input. It will be coerced by the contract implementation and will contain only simple types like strings or integers.
|
145
|
+
Context should never contain any user input and used to pass contextual data like current_user or, say, ActiveRecord models that were fetched from DB before the operation call.
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
create_post.call({ title: "Post Title" }, current_user: current_user)
|
149
|
+
```
|
150
|
+
|
151
|
+
The context will be passed further to every component in the pipeline:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
class Post::Create
|
155
|
+
def call(params, current_user:, **)
|
156
|
+
current_user.posts.new(params)
|
157
|
+
|
158
|
+
Success({})
|
159
|
+
end
|
160
|
+
end
|
161
|
+
```
|
162
|
+
|
163
|
+
A rule of thumb: context conveys all the data you don't want to be passed by the user.
|
164
|
+
|
165
|
+
### Contract
|
166
|
+
|
167
|
+
Besides params coercion, a contract is responsible for filling in additional context. In Dry::Validation, rules are used to perform this:
|
168
|
+
|
169
|
+
```ruby
|
170
|
+
class Post::UpdateContract < Operations::Contract
|
171
|
+
params do
|
172
|
+
required(:post_id).filled(:integer)
|
173
|
+
required(:title).filled(:string)
|
174
|
+
end
|
175
|
+
|
176
|
+
rule(:post_id) do |context:|
|
177
|
+
context[:post] = Post.find(value)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
```
|
181
|
+
|
182
|
+
Then, the operation body can proceed with the found post handling:
|
183
|
+
|
184
|
+
```ruby
|
185
|
+
class Post::Update
|
186
|
+
def call(params, post:, **)
|
187
|
+
post.update(params)
|
188
|
+
|
189
|
+
Success({})
|
190
|
+
end
|
191
|
+
end
|
192
|
+
```
|
193
|
+
|
194
|
+
A more advanced example of finding ActiveRecord records:
|
195
|
+
|
196
|
+
```ruby
|
197
|
+
class Post::UpdateContract < Operations::Contract
|
198
|
+
params do
|
199
|
+
optional(:post_id).filled(:integer)
|
200
|
+
required(:title).filled(:string)
|
201
|
+
end
|
202
|
+
|
203
|
+
rule do |context:|
|
204
|
+
next key.failure(:key?) unless key?(:post_id) && context[:post]
|
205
|
+
|
206
|
+
post = Post.find_by(id: values[:comment_id])
|
207
|
+
|
208
|
+
if post
|
209
|
+
context[:post] = post
|
210
|
+
else
|
211
|
+
key.failure(:not_found)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
```
|
216
|
+
|
217
|
+
**Important:** Please check [Generic preconditions and policies](#generic-preconditions-and-policies) on reasons why we don't assign nil post to the context.
|
218
|
+
|
219
|
+
Now notice that `post_id` param became optional and `required` validation is handled by the rule conditionally. This allows passing either param or a post instance itself if it exists:
|
220
|
+
|
221
|
+
```ruby
|
222
|
+
post_update.call({ post_id: 123 })
|
223
|
+
post_update.call({}, post: post)
|
224
|
+
```
|
225
|
+
|
226
|
+
Both of the calls above are going to work exactly alike but in the first case, there will be an additional database query.
|
227
|
+
|
228
|
+
It is possible to extract context filling into some kind of generic macro:
|
229
|
+
|
230
|
+
```ruby
|
231
|
+
class OperationContract < Operations::Contract
|
232
|
+
def self.find(context_key)
|
233
|
+
rule do |context:|
|
234
|
+
params_key = :"#{name}_id"
|
235
|
+
|
236
|
+
next key.failure(:key?) unless key?(params_key) && context[context_key]
|
237
|
+
|
238
|
+
record = context_key.to_s.classify.constantize.find_by(id: values[params_key])
|
239
|
+
|
240
|
+
if record
|
241
|
+
context[context_key] = record
|
242
|
+
else
|
243
|
+
key.failure(:not_found, tokens: { context_key: context_key })
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
class Post::UpdateContract < OperationContract
|
250
|
+
params do
|
251
|
+
optional(:post_id).filled(:integer)
|
252
|
+
required(:title).filled(:string)
|
253
|
+
end
|
254
|
+
|
255
|
+
find :post
|
256
|
+
end
|
257
|
+
```
|
258
|
+
|
259
|
+
**Important:** contract is solely responsible for populating operation context from given params. At the same time, it should be flexible enough to accept the passed context for optimization purposes.
|
260
|
+
|
261
|
+
### Operation body
|
262
|
+
|
263
|
+
The operation body can be any callable object (respond to the `call` method), even a lambda. But it is always better to define it as a class since there might be additional instance methods and [dependency injections](#dependency-injection).
|
264
|
+
|
265
|
+
In any event, the operation body should return a Dry::Monad::Result instance. In case of a Failure, it will be converted into an `Operation::Result#error` and in case of Success(), its content will be merged into the operation context.
|
266
|
+
|
267
|
+
**Important:** since the Success result payload is merged inside of a context, it is supposed to be a hash.
|
268
|
+
|
269
|
+
### Application container
|
270
|
+
|
271
|
+
Operations are built using the principle: initializers are for [dependencies](#dependency-injection). This means that the Command instance is supposed to be initialized once for the application lifetime and is a perfect candidate for some kind of application container to be stored in.
|
272
|
+
|
273
|
+
But if your application does not have an application container - the best approach would be to use class methods to store Command instances.
|
274
|
+
|
275
|
+
```ruby
|
276
|
+
class Post::Update
|
277
|
+
def self.default
|
278
|
+
@default ||= Operations::Command.new(
|
279
|
+
new,
|
280
|
+
contract: User::CreateContract.new,
|
281
|
+
policy: nil
|
282
|
+
)
|
283
|
+
end
|
284
|
+
|
285
|
+
def call(params, post: **)
|
286
|
+
end
|
287
|
+
end
|
288
|
+
```
|
289
|
+
|
290
|
+
And then this command can be called from anywhere (for example, controller) using:
|
291
|
+
|
292
|
+
```ruby
|
293
|
+
def update
|
294
|
+
post_update = Post::Update.default.call(params[:post], current_user: current_user)
|
295
|
+
|
296
|
+
if post_update.success?
|
297
|
+
redirect_to(post_path(post_update.context[:post].id))
|
298
|
+
else
|
299
|
+
render :edit
|
300
|
+
end
|
301
|
+
end
|
302
|
+
```
|
303
|
+
|
304
|
+
### Dependency Injection
|
305
|
+
|
306
|
+
Dependency injection can be used to provide IO clients with the operation. It could be DB repositories or API clients. The best way is to use Dry::Initializer for it since it provides the ability to define acceptable types.
|
307
|
+
|
308
|
+
If you still prefer to use ActiveRecord, it is worth creating a wrapper around it providing Dry::Monad-compatible interfaces.
|
309
|
+
|
310
|
+
```ruby
|
311
|
+
class ActiveRecordRepository
|
312
|
+
include Dry::Monads[:result]
|
313
|
+
extend Dry::Initializer
|
314
|
+
|
315
|
+
param :model, type: Types.Instance(Class).constrained(lt: ActiveRecord::Base)
|
316
|
+
|
317
|
+
def create(**attributes)
|
318
|
+
record = model.new(**attributes)
|
319
|
+
|
320
|
+
if record.save
|
321
|
+
Success(model.name.underscore.to_sym => record) # Success(post: record)
|
322
|
+
else
|
323
|
+
failure_from_errors(record.errors) # Failure([{ message: "Must be present", code: :blank, path: "title" }])
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
private
|
328
|
+
|
329
|
+
def failure_from_errors(errors)
|
330
|
+
failures = errors.map do |error|
|
331
|
+
{ message: error.message, code: error.type, path: error.attribute }
|
332
|
+
end
|
333
|
+
Failure(failures)
|
334
|
+
end
|
335
|
+
end
|
336
|
+
```
|
337
|
+
|
338
|
+
Then this repository can be used in the operation directly:
|
339
|
+
|
340
|
+
```ruby
|
341
|
+
class Post::Create
|
342
|
+
extend Dry::Initializer
|
343
|
+
|
344
|
+
option :post_repository, default: proc { ActiveRecordRepository.new(Post) }
|
345
|
+
|
346
|
+
def call(params, **)
|
347
|
+
post_repository.create(**params)
|
348
|
+
end
|
349
|
+
end
|
350
|
+
```
|
351
|
+
|
352
|
+
`ActiveRecordRepository#create` returns a proper Success() monad which will become a part of `Operation::Result#context` returned by Composite or a properly built Failure() monad which will be incorporated into `Operation::Result#errors`.
|
353
|
+
|
354
|
+
Of course, it is possible to use [dry-auto_inject](https://dry-rb.org/gems/dry-auto_inject/) along with [dry-container](https://dry-rb.org/gems/dry-container/) to make things even fancier.
|
355
|
+
|
356
|
+
### Configuration
|
357
|
+
|
358
|
+
The gem has a global default configuration:
|
359
|
+
|
360
|
+
```ruby
|
361
|
+
Operations.configure(
|
362
|
+
error_reporter: -> (message, payload) { Sentry.capture_message(message, extra: payload) },
|
363
|
+
)
|
364
|
+
```
|
365
|
+
|
366
|
+
But also, a configuration instance can be passed directly to a Command initializer (for example, to switch off the wrapping DB transaction for a single operation):
|
367
|
+
|
368
|
+
```ruby
|
369
|
+
Operations::Command.new(..., configuration: Operations.default_config.new(transaction: -> {}))
|
370
|
+
```
|
371
|
+
|
372
|
+
It is possible to call `configuration_instance.new` to receive an updated configuration instance since it is a `Dry::Struct`
|
373
|
+
|
374
|
+
### Preconditions
|
375
|
+
|
376
|
+
When we need to check against the application state, preconditions are coming to help. Obviously, we can do all those checks in Contract rule definitions but it is great to have separate kinds of components (a separate stage in the pipeline) for this particular reason as it gives the ability to check them in isolation.
|
377
|
+
|
378
|
+
There are many potential scenarios when it can be handy. For example, we might need to render a button only when the subject entity satisfies preconditions for a particular operation. Or we want to return a list of possible operations from an API we have.
|
379
|
+
|
380
|
+
**Important:** a rule of thumb here is that preconditions don't depend on the user input, they only check the existing state of the application and they are supposed to access only the operation context for this purpose, not params.
|
381
|
+
|
382
|
+
```ruby
|
383
|
+
class Post::Publish
|
384
|
+
def self.default
|
385
|
+
@default ||= Operations::Command.new(
|
386
|
+
new,
|
387
|
+
contract: Contract.new,
|
388
|
+
policy: nil,
|
389
|
+
preconditions: [NotPublishedPrecondition.new]
|
390
|
+
)
|
391
|
+
end
|
392
|
+
|
393
|
+
def call(_, post:, **)
|
394
|
+
post.update(published_at: Time.zone.now)
|
395
|
+
|
396
|
+
Success({})
|
397
|
+
end
|
398
|
+
end
|
399
|
+
|
400
|
+
class Post::Publish::Contract < OperationContract
|
401
|
+
params do
|
402
|
+
optional(:post_id).filled(:integer)
|
403
|
+
end
|
404
|
+
|
405
|
+
find :post
|
406
|
+
end
|
407
|
+
|
408
|
+
class Post::Publish::NotPublishedPrecondition
|
409
|
+
include Dry::Monads[:result]
|
410
|
+
|
411
|
+
def call(post:, **)
|
412
|
+
return Failure(:already_published) if post.published?
|
413
|
+
|
414
|
+
Success()
|
415
|
+
end
|
416
|
+
end
|
417
|
+
```
|
418
|
+
|
419
|
+
Precondition is supposed to return either a Success() monad if an operation is ok to proceed with the updates or `Failure(:error_symbol)` if we want to interrupt the operation execution.
|
420
|
+
|
421
|
+
Besides just a symbol, it is possible to return a Failure with a hash:
|
422
|
+
|
423
|
+
```ruby
|
424
|
+
class Post::Publish::NotPublishedPrecondition
|
425
|
+
include Dry::Monads[:result]
|
426
|
+
|
427
|
+
def call(post:, **)
|
428
|
+
return Failure(error: :already_published, tokens: { published_at: post.published_at }) if post.published?
|
429
|
+
|
430
|
+
Success()
|
431
|
+
end
|
432
|
+
end
|
433
|
+
```
|
434
|
+
|
435
|
+
Then `tokens:` values can be used in the translation string: `Post is already published at %{published_at}` as a way to provide more context to the end user.
|
436
|
+
|
437
|
+
```ruby
|
438
|
+
result = Post::Publish.default.call({ post_id: 123 }) #=>
|
439
|
+
# #<Operations::Result ...>,
|
440
|
+
# component = :preconditions,
|
441
|
+
# params = {:post_id=>123},
|
442
|
+
# context = {:post=>#<Post id=123, ...>},
|
443
|
+
# on_success = [],
|
444
|
+
# on_failure = [],
|
445
|
+
# errors = #<Dry::Validation::MessageSet messages=[
|
446
|
+
# #<Dry::Validation::Message text="Post is already published at 20.02.2023 12:00" path=[nil] meta={:code=>:already_published}>
|
447
|
+
# ] options={}>>
|
448
|
+
result.failed_precheck? #=> true
|
449
|
+
result.failed_precondition? #=> true
|
450
|
+
result.failed_policy? #=> false
|
451
|
+
result.failed_precondition?(:already_published) #=> true
|
452
|
+
result.failed_precheck?(:already_published) #=> true
|
453
|
+
result.failed_precondition?(:another_code) #=> false
|
454
|
+
result.failed_precheck?(:another_code) #=> false
|
455
|
+
```
|
456
|
+
|
457
|
+
Alternatively, it is possible to return either just an error_symbol or `nil` from the precondition where nil is interpreted as a lack of error. In this case, the precondition becomes a bit less bulky:
|
458
|
+
|
459
|
+
```ruby
|
460
|
+
class Post::Publish::NotPublishedPrecondition
|
461
|
+
def call(post:, **)
|
462
|
+
:already_published if post.published?
|
463
|
+
end
|
464
|
+
end
|
465
|
+
```
|
466
|
+
|
467
|
+
It is up to the developer which notion to use but we recommend a uniform application-wide approach to be established.
|
468
|
+
|
469
|
+
To resolve an error message from an error code, the contract's MessageResolver is used. So the rules are the same as for the failures returned by `Operations::Contract`.
|
470
|
+
|
471
|
+
It is possible to pass multiple preconditions. They will be evaluated all at once and if even one of them fails - the operation fails as well.
|
472
|
+
|
473
|
+
```ruby
|
474
|
+
class Post::Publish
|
475
|
+
def self.default
|
476
|
+
@default ||= Operations::Command.new(
|
477
|
+
new,
|
478
|
+
contract: Contract.new,
|
479
|
+
policy: nil,
|
480
|
+
preconditions: [
|
481
|
+
NotPublishedPrecondition.new,
|
482
|
+
ApprovedPrecondition.new
|
483
|
+
]
|
484
|
+
)
|
485
|
+
end
|
486
|
+
end
|
487
|
+
|
488
|
+
class Post::Publish::ApprovedPrecondition
|
489
|
+
def call(post:, **)
|
490
|
+
:not_approved_yet unless post.approved?
|
491
|
+
end
|
492
|
+
end
|
493
|
+
```
|
494
|
+
|
495
|
+
Now we want to render a button in the interface:
|
496
|
+
|
497
|
+
```erb
|
498
|
+
<% if Post::Publish.default.callable?(post: @post) %>
|
499
|
+
<% link_to "Publish post", publish_post_url, method: :patch %>
|
500
|
+
<% end %>
|
501
|
+
```
|
502
|
+
|
503
|
+
In this case, you may notice that the post was found before in the controller action and since we have a smart finder rule in the contract, the operation is not going to need `post_id` param and will utilize the given `@post` instance.
|
504
|
+
|
505
|
+
There are 4 methods to be used for such checks:
|
506
|
+
|
507
|
+
* `possible(**context)` - returns an operation result (success or failure depending on precondition checks result). Useful when you need to check the exact error that happened.
|
508
|
+
* `possible?(**context)` - the same as the previous one but returns a boolean result.
|
509
|
+
* `callable(**context)` - checks for both preconditions and [policies](#policies).
|
510
|
+
* `callable?(**context)` - the same as the previous one but returns a boolean result.
|
511
|
+
|
512
|
+
`callable/callable?` will be the method used in 99% of cases, there are very few situations when one needs to check preconditions separately from policies.
|
513
|
+
|
514
|
+
### Policies
|
515
|
+
|
516
|
+
Now we need to check if the current actor can perform the operation. Policies are utilized for this purpose:
|
517
|
+
|
518
|
+
```ruby
|
519
|
+
class Post::Publish
|
520
|
+
def self.default
|
521
|
+
@default ||= Operations::Command.new(
|
522
|
+
new,
|
523
|
+
contract: Contract.new,
|
524
|
+
policy: AuthorPolicy.new,
|
525
|
+
)
|
526
|
+
end
|
527
|
+
|
528
|
+
def call(_, post:, **)
|
529
|
+
post.update(published_at: Time.zone.now)
|
530
|
+
|
531
|
+
Success({})
|
532
|
+
end
|
533
|
+
end
|
534
|
+
|
535
|
+
class Post::Publish::AuthorPolicy
|
536
|
+
def call(post:, current_user:, **)
|
537
|
+
post.author == current_user
|
538
|
+
end
|
539
|
+
end
|
540
|
+
```
|
541
|
+
|
542
|
+
The difference between policies and preconditions is simple: policies generally involve current actor checks against the rest of the context while preconditions check the rest of the context without the actor's involvement. Both should be agnostic of user input.
|
543
|
+
|
544
|
+
Policies are separate from preconditions because in we usually treat the results of those checks differently. For example, if an operation fails on preconditions we might want to render a disabled button with an error message as a hint but if the policy fails - we don't render the button at all.
|
545
|
+
|
546
|
+
Similarly to preconditions, policies are having 2 returned values signatures: `true/false` and `Success()/Failure(:error_code)`. In case of `false`, an `:unauthorized` error code will be returned as a result. It is possible to return any error code using the `Failure` monad.
|
547
|
+
|
548
|
+
```ruby
|
549
|
+
class Post::Publish::AuthorPolicy
|
550
|
+
include Dry::Monads[:result]
|
551
|
+
|
552
|
+
def call(post:, current_user:, **)
|
553
|
+
post.author == current_user ? Success() : Failure(:not_an_author)
|
554
|
+
end
|
555
|
+
end
|
556
|
+
```
|
557
|
+
|
558
|
+
It is possible to pass multiple policies, and all of them have to succeed similarly to preconditions. Though it is impossible not to pass any policies for security reasons. If an operation is internal to the system and not exposed to the end user, `policy: nil` should be passed to `Operations::Command` instance anyway just to explicitly specify that this operation doesn't have any policy.
|
559
|
+
|
560
|
+
There are 2 more methods to check for policies separately:
|
561
|
+
|
562
|
+
* `allowed(**context)` - returns an operation result (success or failure depending on policy checks result). Useful when you need to check the exact error that happened.
|
563
|
+
* `allowed?(**context)` - the same as the previous one but returns a boolean result.
|
564
|
+
|
565
|
+
### ActiveRecord scopes usage
|
566
|
+
|
567
|
+
There might be a temptation to use AR scopes while fetching records from the DB. For example:
|
568
|
+
|
569
|
+
```ruby
|
570
|
+
class Post::Publish::Contract < Operations::Contract
|
571
|
+
params do
|
572
|
+
optional(:post_id).filled(:integer)
|
573
|
+
end
|
574
|
+
|
575
|
+
rule do |context:|
|
576
|
+
next key.failure(:key?) unless key?(:post_id) && context[:post]
|
577
|
+
|
578
|
+
post = context[:current_user].posts.publishable.find_by(id: values[:post_id])
|
579
|
+
|
580
|
+
if post
|
581
|
+
context[:post] = post
|
582
|
+
else
|
583
|
+
key.failure(:not_found) unless post
|
584
|
+
end
|
585
|
+
end
|
586
|
+
end
|
587
|
+
```
|
588
|
+
|
589
|
+
Please avoid this at all costs since:
|
590
|
+
|
591
|
+
1. It is possible to pass context variables like `post` explicitly and there is no guarantee that it will be scoped.
|
592
|
+
2. We want to return an explicit `unauthorized/not_publishable` error to the user instead of a cryptic `not_found`.
|
593
|
+
3. It does not fit the concept of repositories.
|
594
|
+
|
595
|
+
If we want to use security through obscurity in the controller later - we can easily turn a particular operation error into an `ActiveRecord::RecordNotFound` exception at will.
|
596
|
+
|
597
|
+
There could be non-domain but rather technical scopes like soft deletion used as an exception but this is a rare case and it should be carefully considered.
|
598
|
+
|
599
|
+
### Generic preconditions and policies
|
600
|
+
|
601
|
+
Normally we would expect the following execution order of the operation:
|
602
|
+
|
603
|
+
1. Set all the context
|
604
|
+
2. Check policies
|
605
|
+
3. Check preconditions
|
606
|
+
4. Validate user input
|
607
|
+
5. Call operation if everything is valid
|
608
|
+
|
609
|
+
This order is expected because it doesn't even make sense to validate user input if the state of the system or the current actor doesn't fit the requirements for the operation to be performed. This is also useful since we want to check if an operation is callable in some instances but we don't have user input at this point (e.g. on operation button rendering).
|
610
|
+
|
611
|
+
Unfortunately, to set the context, we need some of the user input (like `post_id`) to be validated. Separating context-filling params from the rest of them would cause 2 different contracts and other unpleasant or even ugly solutions. So to keep a single contract, the decision was to implement the following algorithm:
|
612
|
+
|
613
|
+
1. Validate user input in the contract
|
614
|
+
2. Try to set context if possible in contract rules
|
615
|
+
3. If the contract fails, don't return failure just yet
|
616
|
+
4. Check policies if we have all the required context set
|
617
|
+
5. Check preconditions if we have all the required context set
|
618
|
+
6. Return contract error if it was impossible to check preconditions/policies or they have passed
|
619
|
+
7. Call operation if everything is valid
|
620
|
+
|
621
|
+
This way we don't have to separate user input validation but the results returned will be very close to what the first routine would produce.
|
622
|
+
|
623
|
+
Since all the context variables are passed as kwargs to policies/preconditions, it is quite simple to determine if we have all the context required to run those policies/preconditions:
|
624
|
+
|
625
|
+
```ruby
|
626
|
+
class Comment::Update::NotSoftDeletedPrecondition
|
627
|
+
def call(comment:, **)
|
628
|
+
:soft_deleted if comment.deleted_at?
|
629
|
+
end
|
630
|
+
end
|
631
|
+
```
|
632
|
+
|
633
|
+
Now we decided that we want this precondition to apply to all the models and be universal.
|
634
|
+
|
635
|
+
```ruby
|
636
|
+
class Preconditions::NotSoftDeleted
|
637
|
+
extend Dry::Initializer
|
638
|
+
|
639
|
+
param :context_key, Types::Symbol
|
640
|
+
|
641
|
+
def call(**context)
|
642
|
+
:soft_deleted if context[context_key].deleted_at?
|
643
|
+
end
|
644
|
+
end
|
645
|
+
|
646
|
+
class Comment::Update
|
647
|
+
def self.default
|
648
|
+
@default ||= Operations::Command.new(
|
649
|
+
new,
|
650
|
+
contract: Contract.new,
|
651
|
+
preconditions: [Preconditions::NotSoftDeleted.new(:comment)],
|
652
|
+
policy: nil
|
653
|
+
)
|
654
|
+
end
|
655
|
+
end
|
656
|
+
```
|
657
|
+
|
658
|
+
In this example, we pass the context key to check in the precondition initializer. And the algorithm that checks for the filled context is now unable to determine the required kwargs since there are no kwargs.
|
659
|
+
|
660
|
+
Fortunately, `context_key` or `context_keys` are magic parameter names that are also considered by this algorithm along with kwargs, so this example will do the trick and these magic variables are making generic policies/preconditions possible to define.
|
661
|
+
|
662
|
+
```ruby
|
663
|
+
class Policies::BelongsToUser
|
664
|
+
extend Dry::Initializer
|
665
|
+
|
666
|
+
param :context_key, Types::Symbol
|
667
|
+
|
668
|
+
def call(current_user: **context)
|
669
|
+
context[context_key].user == current_user
|
670
|
+
end
|
671
|
+
end
|
672
|
+
|
673
|
+
class Comment::Update
|
674
|
+
def self.default
|
675
|
+
@default ||= Operations::Command.new(
|
676
|
+
new,
|
677
|
+
contract: Contract.new,
|
678
|
+
policy: Policies::BelongsToUser.new(:comment)
|
679
|
+
)
|
680
|
+
end
|
681
|
+
end
|
682
|
+
```
|
683
|
+
|
684
|
+
In the examples above we safely assume that the context key is present in the case when the contract and context population is implemented the following way:
|
685
|
+
|
686
|
+
```ruby
|
687
|
+
class Comment::Update::Contract < OperationContract
|
688
|
+
params do
|
689
|
+
optional(:comment_id).filled(:integer)
|
690
|
+
end
|
691
|
+
|
692
|
+
find :comment
|
693
|
+
end
|
694
|
+
```
|
695
|
+
|
696
|
+
The context key is not even set if the object was not found. In this case, the context will be considered insufficient and operation policies/preconditions will not be even called accordingly to the algorithm described above.
|
697
|
+
|
698
|
+
### Callbacks (on_success, on_failure)
|
699
|
+
|
700
|
+
Sometimes we need to run further application state modifications outside of the operation transaction. For this purpose, there are 2 separate callbacks: `on_success` and `on_failure`.
|
701
|
+
|
702
|
+
The key difference besides one running after operation success and another - after failure, is that `on_success` runs after the transaction commit. This means that if one operation calls another operation inside of it and the inner one has `on_success` callbacks defined - the callbacks are going to be executed only after the outermost transaction is committed successfully.
|
703
|
+
|
704
|
+
To achieve this, the framework utilizes the [after_commit_everywhere](https://github.com/Envek/after_commit_everywhere) gem and the behavior is configurable using `Operations::Configuration#after_commit` option.
|
705
|
+
|
706
|
+
It is a good idea to use these callbacks to schedule some jobs instead of just running inline code since if callback execution fails - the failure will be ignored and the operation is still going to be successful. Though the failure from both callbacks will be reported using `Operations::Configuration#error_reporter` and using Sentry by default.
|
707
|
+
|
708
|
+
```ruby
|
709
|
+
class Comment::Update
|
710
|
+
def self.default
|
711
|
+
@default ||= Operations::Command.new(
|
712
|
+
...,
|
713
|
+
on_success: [
|
714
|
+
PublishCommentUpdatedEvent.new,
|
715
|
+
NotifyBoardAdmin.new
|
716
|
+
]
|
717
|
+
)
|
718
|
+
end
|
719
|
+
end
|
720
|
+
|
721
|
+
class PublishCommentUpdatedEvent
|
722
|
+
def call(params, comment:, **)
|
723
|
+
PublishEventJob.perform_later('comment', comment, params: params)
|
724
|
+
end
|
725
|
+
end
|
726
|
+
|
727
|
+
class NotifyBoardAdmin
|
728
|
+
def call(_, comment:, **)
|
729
|
+
AdminMailer.comment_updated(comment.id)
|
730
|
+
end
|
731
|
+
end
|
732
|
+
```
|
733
|
+
|
734
|
+
Additionally, a callback `call` method can receive the operation result instead of params and context. This enables powerful introspection for generic callbacks.
|
735
|
+
|
736
|
+
```ruby
|
737
|
+
class PublishCommentUpdatedEvent
|
738
|
+
def call(operation_result)
|
739
|
+
PublishEventJob.perform_later(
|
740
|
+
'comment',
|
741
|
+
operation_result.context[:comment],
|
742
|
+
params: operation_result.params,
|
743
|
+
operation_name: operation_result.operation.operation.class.name.underscore
|
744
|
+
)
|
745
|
+
end
|
746
|
+
end
|
747
|
+
```
|
748
|
+
|
749
|
+
### Idempotency checks
|
750
|
+
|
751
|
+
Idempotency checks are used to skip the operation body in certain conditions. It is very similar to preconditions but if the idempotency check fails - the operation will be successful anyway. This is useful in cases when we want to ensure that operation is not going to run for the second time even if it was called, and especially for idempotent consumer pattern implementation in event-driven systems.
|
752
|
+
|
753
|
+
Normally, we advise for idempotency checks not to use the same logic which would be used for preconditions, i.e. not to use application business state checks. Instead, it is worth implementing a separate mechanism like `ProcessedEvents` DB table.
|
754
|
+
|
755
|
+
Idempotency checks are running after policy checks but before preconditions.
|
756
|
+
|
757
|
+
```ruby
|
758
|
+
class Order::MarkAsCompleted
|
759
|
+
def self.default
|
760
|
+
@default ||= Operations::Command.new(
|
761
|
+
new,
|
762
|
+
contract: Order::MarkAsCompleted::Contract.new,
|
763
|
+
policy: nil,
|
764
|
+
idempotency: [Idempotency::ProcessedEvents.new],
|
765
|
+
preconditions: [Order::RequireStatus.new(:processing)]
|
766
|
+
)
|
767
|
+
end
|
768
|
+
end
|
769
|
+
|
770
|
+
class Order::MarkAsCompleted::Contract < OperationContract
|
771
|
+
params do
|
772
|
+
# event_id is optional and the operation can be called without it, i.e. from the console.
|
773
|
+
optional(:event_id).filled(Types::UUID)
|
774
|
+
optional(:order_id).filled(:integer)
|
775
|
+
end
|
776
|
+
|
777
|
+
find :order
|
778
|
+
end
|
779
|
+
|
780
|
+
class Order::RequireStatus
|
781
|
+
extend Dry::Initializer
|
782
|
+
include Dry::Monads[:result]
|
783
|
+
|
784
|
+
param :statuses, [Types::Symbol]
|
785
|
+
|
786
|
+
def call(order:, **)
|
787
|
+
return Failure(error: :invalid_status, tokens: { status: order.status }) unless order.status.in?(statuses)
|
788
|
+
|
789
|
+
Success()
|
790
|
+
end
|
791
|
+
end
|
792
|
+
|
793
|
+
class Idempotency::ProcessedEvents
|
794
|
+
include Dry::Monads[:result]
|
795
|
+
|
796
|
+
# Notice that, unlike preconditions, idempotency checks have params provided
|
797
|
+
def call(params, **)
|
798
|
+
return Success() unless params.key?(:event_id)
|
799
|
+
# Assume that `ProcessedEvents` has a unique index on `event_id`
|
800
|
+
ProcessedEvents.create!(event_id: params[:event_id])
|
801
|
+
Success()
|
802
|
+
rescue ActiveRecord::StatementInvalid
|
803
|
+
Failure({})
|
804
|
+
end
|
805
|
+
end
|
806
|
+
```
|
807
|
+
|
808
|
+
**Important:** contrary to the operation, idempotency checks require to return a hash in Failure monad. This hash will be merged into the resulting context. This is necessary for operations interrupted during idempotency checks to return the same result as at the first run.
|
809
|
+
|
810
|
+
It might be also worth defining 2 different operations that will be called in different circumstances to reduce human error:
|
811
|
+
|
812
|
+
```ruby
|
813
|
+
class Order::MarkAsCompleted
|
814
|
+
def self.system
|
815
|
+
@system ||= Operations::Command.new(
|
816
|
+
new,
|
817
|
+
contract: Order::MarkAsCompleted::SystemContract.new,
|
818
|
+
policy: nil,
|
819
|
+
preconditions: [Order::RequireStatus.new(:processing)]
|
820
|
+
)
|
821
|
+
end
|
822
|
+
|
823
|
+
# We use `merge` method here to dry the code a bit.
|
824
|
+
def self.kafka
|
825
|
+
@kafka ||= system.merge(
|
826
|
+
contract: Order::MarkAsCompleted::KafkaContract.new,
|
827
|
+
idempotency: [Idempotency::ProcessedEvents.new],
|
828
|
+
)
|
829
|
+
end
|
830
|
+
end
|
831
|
+
|
832
|
+
class Order::MarkAsCompleted::SystemContract < OperationContract
|
833
|
+
params do
|
834
|
+
optional(:order_id).filled(:integer)
|
835
|
+
end
|
836
|
+
|
837
|
+
find :order
|
838
|
+
end
|
839
|
+
|
840
|
+
# All the params and rules are inherited
|
841
|
+
class Order::MarkAsCompleted::KafkaContract < Order::MarkAsCompleted::SystemContract
|
842
|
+
params do
|
843
|
+
required(:event_id).filled(Types::UUID)
|
844
|
+
end
|
845
|
+
end
|
846
|
+
```
|
847
|
+
|
848
|
+
In this case, `Order::MarkAsCompleted.system.call(...)` will be used in, say, console, and `Order::MarkAsCompleted.kafka.call(...)` will be used on Kafka event consumption.
|
849
|
+
|
850
|
+
### Convenience helpers
|
851
|
+
|
852
|
+
`Operations::Convenience` is an optional module that contains helpers for simpler operation definitions. See module documentation for more details.
|
853
|
+
|
854
|
+
### Form objects
|
855
|
+
|
856
|
+
While we normally recommend using frontend-backend separation, it is still possible to use this framework with Rails view helpers:
|
857
|
+
|
858
|
+
```ruby
|
859
|
+
class PostsController < ApplicationController
|
860
|
+
def edit
|
861
|
+
@post_update = Post::Update.default.callable(
|
862
|
+
{ post_id: params[:id] },
|
863
|
+
current_user: current_user
|
864
|
+
)
|
865
|
+
|
866
|
+
respond_with @post_update
|
867
|
+
end
|
868
|
+
|
869
|
+
def update
|
870
|
+
# With operations we don't need strong parameters as the operation contract takes care of this.
|
871
|
+
@post_update = Post::Update.default.call(
|
872
|
+
{ **params[:post_update_default_form], post_id: params[:id] },
|
873
|
+
current_user: current_user
|
874
|
+
)
|
875
|
+
|
876
|
+
respond_with @post_update, location: edit_post_url(@post_update.context[:post])
|
877
|
+
end
|
878
|
+
end
|
879
|
+
```
|
880
|
+
|
881
|
+
The key here is to use `Operations::Result#form` method for the form builder.
|
882
|
+
|
883
|
+
```erb
|
884
|
+
# views/posts/edit.html.erb
|
885
|
+
<%= form_for @post_update.form, url: post_url(@post_update.context[:post]), method: :patch do |f| %>
|
886
|
+
<%= f.input :title %>
|
887
|
+
<%= f.text_area :body %>
|
888
|
+
<% end %>
|
889
|
+
```
|
890
|
+
|
891
|
+
In cases when we need to populate the form data, it is possible to pass `form_hydrator:`:
|
892
|
+
|
893
|
+
```ruby
|
894
|
+
class Post::Update
|
895
|
+
def self.default
|
896
|
+
@default ||= Operations::Command.new(
|
897
|
+
...,
|
898
|
+
form_hydrator: Post::Update::Hydrator.new
|
899
|
+
)
|
900
|
+
end
|
901
|
+
end
|
902
|
+
|
903
|
+
class Post::Update::Hydrator
|
904
|
+
def call(form_class, params, post:, **)
|
905
|
+
value_attributes = form_class.attributes.keys - %i[post_id]
|
906
|
+
data = value_attributes.index_with { |name| post.public_send(name) }
|
907
|
+
|
908
|
+
data.merge!(params)
|
909
|
+
end
|
910
|
+
end
|
911
|
+
```
|
912
|
+
|
913
|
+
The general idea here is to figure out attributes we have in the contract (those attributes are also defined automatically in a generated form class) and then fetch those attributes from the model and merge them with the params provided within the request.
|
914
|
+
|
915
|
+
Also, in the case of, say, [simple_form](https://github.com/heartcombo/simple_form), we need to provide additional attributes information, like data type. It is possible to do this with an optional `form_model_map:` operation option:
|
916
|
+
|
917
|
+
```ruby
|
918
|
+
class Post::Update
|
919
|
+
def self.default
|
920
|
+
@default ||= Operations::Command.new(
|
921
|
+
...,
|
922
|
+
form_hydrator: Post::Update::Hydrator.new,
|
923
|
+
form_model_map: {
|
924
|
+
[%r{.+}] => "Post"
|
925
|
+
}
|
926
|
+
)
|
927
|
+
end
|
928
|
+
end
|
929
|
+
```
|
930
|
+
|
931
|
+
Here we define all the fields mapping to a model class with a regexp or just string values.
|
26
932
|
|
27
933
|
## Development
|
28
934
|
|
@@ -32,8 +938,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
32
938
|
|
33
939
|
## Contributing
|
34
940
|
|
35
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
36
|
-
|
941
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/BookingSync/operations.
|
37
942
|
|
38
943
|
## License
|
39
944
|
|