opera 0.4.1 → 0.5.1

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.
data/README.md CHANGED
@@ -3,550 +3,37 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/opera.svg)](https://badge.fury.io/rb/opera)
4
4
  ![Master](https://github.com/Profinda/opera/actions/workflows/release.yml/badge.svg?branch=master)
5
5
 
6
+ A lightweight DSL for building operations, services and interactions in Ruby. Zero runtime dependencies.
6
7
 
7
- Simple DSL for services/interactions classes.
8
+ Opera gives developers a consistent way to structure business logic as a pipeline of steps -- validate, execute, handle errors -- with a declarative DSL at the top of each class that makes the flow immediately readable.
8
9
 
9
- Opera was born to mimic some of the philosophy of the dry gems but keeping the DSL simple.
10
-
11
- Our aim was and is to write as many Operations, Services and Interactions using this fun and intuitive DSL to help developers have consistent code, easy to understand and maintain.
12
-
13
- ## Installation
14
-
15
- Add this line to your application's Gemfile:
16
-
17
- ```ruby
18
- gem 'opera'
19
- ```
20
-
21
- And then execute:
22
-
23
- $ bundle install
24
-
25
- Or install it yourself as:
26
-
27
- $ gem install opera
28
-
29
- Note. If you are using Ruby 2.x please use Opera 0.2.x
30
-
31
- ## Configuration
32
-
33
- Opera is built to be used with or without Rails.
34
- Simply initialize the configuration and choose a custom logger and which library to use for implementing transactions.
35
-
36
- ```ruby
37
- Opera::Operation::Config.configure do |config|
38
- config.transaction_class = ActiveRecord::Base
39
- config.transaction_method = :transaction
40
- config.transaction_options = { requires_new: true, level: :step } # or level: :operation - default
41
- config.instrumentation_class = Datadog::Tracing
42
- config.instrumentation_method = :trace
43
- config.instrumentation_options = { service: :operation }
44
- config.mode = :development # Can be set to production too
45
- config.reporter = defined?(Rollbar) ? Rollbar : Rails.logger
46
- end
47
- ```
48
-
49
- You can later override this configuration in each Operation to have more granularity
50
-
51
-
52
- ## Usage
53
-
54
- Once opera gem is in your project you can start to build Operations
55
-
56
- ```ruby
57
- class A < Opera::Operation::Base
58
- configure do |config|
59
- config.transaction_class = Profile
60
- config.reporter = Rails.logger
61
- end
62
-
63
- success :populate
64
-
65
- operation :inner_operation
66
-
67
- validate :profile_schema
68
-
69
- transaction do
70
- step :create
71
- step :update
72
- step :destroy
73
- end
74
-
75
- validate do
76
- step :validate_object
77
- step :validate_relationships
78
- end
79
-
80
- benchmark do
81
- success :hal_sync
82
- end
83
-
84
- success do
85
- step :send_mail
86
- step :report_to_audit_log
87
- end
88
-
89
- step :output
90
- end
91
- ```
92
-
93
- Start developing your business logic, services and interactions as Opera::Operations and benefit of code that is documented, self-explanatory, easy to maintain and debug.
94
-
95
-
96
- ### Specs
97
-
98
- When using Opera::Operation inside an engine add the following
99
- configuration to your spec_helper.rb or rails_helper.rb:
100
-
101
- ```ruby
102
- Opera::Operation::Config.configure do |config|
103
- config.transaction_class = ActiveRecord::Base
104
- end
105
- ```
106
-
107
- Without this extra configuration you will receive:
108
- ```ruby
109
- NoMethodError:
110
- undefined method `transaction' for nil:NilClass
111
- ```
112
-
113
- ### Instrumentation
114
-
115
- When you want to easily instrument your operations you can add this to the opera config:
116
-
117
- ```ruby
118
- Rails.application.configure do
119
- config.x.instrumentation_class = Datadog::Tracing
120
- config.x.instrumentation_method = :trace
121
- config.x.instrumentation_options = { service: :opera }
122
- end
123
- ```
124
-
125
- You can also instrument individual operations by adding this to the operation config:
126
-
127
- ```ruby
128
- class A < Opera::Operation::Base
129
- configure do |config|
130
- config.instrumentation_class = Datadog::Tracing
131
- config.instrumentation_method = :trace
132
- config.instrumentation_options = { service: :opera, level: :step }
133
- end
134
-
135
- # steps
136
- end
137
- ```
138
-
139
- ### Content
140
- [Basic operation](#user-content-basic-operation)
141
-
142
- [Example with sanitizing parameters](#user-content-example-with-sanitizing-parameters)
143
-
144
- [Example operation with old validations](#user-content-example-operation-with-old-validations)
145
-
146
- [Failing transaction](#user-content-failing-transaction)
147
-
148
- [Passing transaction](#user-content-passing-transaction)
149
-
150
- [Benchmark](#user-content-benchmark)
151
-
152
- [Success](#user-content-success)
153
-
154
- [Finish if](#user-content-finish-if)
155
-
156
- [Inner Operation](#user-content-inner-operation)
157
-
158
- [Inner Operations](#user-content-inner-operations)
159
-
160
- ## Usage examples
161
-
162
- Some cases and example how to use new operations
163
-
164
- ### Basic operation
165
-
166
- ```ruby
167
- class Profile::Create < Opera::Operation::Base
168
- # DEPRECATED
169
- # context_accessor :profile
170
- context do
171
- attr_accessor :profile
172
- end
173
- # DEPRECATED
174
- # dependencies_reader :current_account, :mailer
175
- dependencies do
176
- attr_reader :current_account, :mailer
177
- end
178
-
179
- validate :profile_schema
180
-
181
- step :create
182
- step :send_email
183
- step :output
184
-
185
- def profile_schema
186
- Dry::Validation.Schema do
187
- required(:first_name).filled
188
- end.call(params)
189
- end
190
-
191
- def create
192
- self.profile = current_account.profiles.create(params)
193
- end
194
-
195
- def send_email
196
- mailer&.send_mail(profile: profile)
197
- end
198
-
199
- def output
200
- result.output = { model: profile }
201
- end
202
- end
203
- ```
204
-
205
- #### Call with valid parameters
206
-
207
- ```ruby
208
- Profile::Create.call(params: {
209
- first_name: :foo,
210
- last_name: :bar
211
- }, dependencies: {
212
- mailer: MyMailer,
213
- current_account: Account.find(1)
214
- })
215
-
216
- #<Opera::Operation::Result:0x0000561636dced60 @errors={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 30, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-14 16:04:08", updated_at: "2020-08-14 16:04:08", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
217
- ```
218
-
219
- #### Call with INVALID parameters - missing first_name
220
-
221
- ```ruby
222
- Profile::Create.call(params: {
223
- last_name: :bar
224
- }, dependencies: {
225
- mailer: MyMailer,
226
- current_account: Account.find(1)
227
- })
228
-
229
- #<Opera::Operation::Result:0x0000562d3f635390 @errors={:first_name=>["is missing"]}, @information={}, @executions=[:profile_schema]>
230
- ```
231
-
232
- #### Call with MISSING dependencies
233
-
234
- ```ruby
235
- Profile::Create.call(params: {
236
- first_name: :foo,
237
- last_name: :bar
238
- }, dependencies: {
239
- current_account: Account.find(1)
240
- })
241
-
242
- #<Opera::Operation::Result:0x007f87ba2c8f00 @errors={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 33, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2019-01-03 12:04:25", updated_at: "2019-01-03 12:04:25", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
243
- ```
244
-
245
- ### Example with sanitizing parameters
246
-
247
- ```ruby
248
- class Profile::Create < Opera::Operation::Base
249
- # DEPRECATED
250
- # context_accessor :profile
251
- context do
252
- attr_accessor :profile
253
- end
254
- # DEPRECATED
255
- # dependencies_reader :current_account, :mailer
256
- dependencies do
257
- attr_reader :current_account, :mailer
258
- end
259
-
260
-
261
- validate :profile_schema
262
-
263
- step :create
264
- step :send_email
265
- step :output
266
-
267
- def profile_schema
268
- Dry::Validation.Schema do
269
- configure { config.input_processor = :sanitizer }
270
-
271
- required(:first_name).filled
272
- end.call(params)
273
- end
274
-
275
- def create
276
- self.profile = current_account.profiles.create(context[:profile_schema_output])
277
- end
278
-
279
- def send_email
280
- return true unless mailer
281
-
282
- mailer.send_mail(profile: profile)
283
- end
284
-
285
- def output
286
- result.output = { model: profile }
287
- end
288
- end
289
- ```
290
-
291
- ```ruby
292
- Profile::Create.call(params: {
293
- first_name: :foo,
294
- last_name: :bar
295
- }, dependencies: {
296
- mailer: MyMailer,
297
- current_account: Account.find(1)
298
- })
299
-
300
- # NOTE: Last name is missing in output model
301
- #<Opera::Operation::Result:0x000055e36a1fab78 @errors={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 44, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: nil, created_at: "2020-08-17 11:07:08", updated_at: "2020-08-17 11:07:08", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
302
- ```
303
-
304
- ### Example operation with old validations
305
-
306
- ```ruby
307
- class Profile::Create < Opera::Operation::Base
308
- # DEPRECATED
309
- # context_accessor :profile
310
- context do
311
- attr_accessor :profile
312
- end
313
- # DEPRECATED
314
- # dependencies_reader :current_account, :mailer
315
- dependencies do
316
- attr_reader :current_account, :mailer
317
- end
318
-
319
- validate :profile_schema
320
-
321
- step :build_record
322
- step :old_validation
323
- step :create
324
- step :send_email
325
- step :output
326
-
327
- def profile_schema
328
- Dry::Validation.Schema do
329
- required(:first_name).filled
330
- end.call(params)
331
- end
332
-
333
- def build_record
334
- self.profile = current_account.profiles.build(params)
335
- self.profile.force_name_validation = true
336
- end
337
-
338
- def old_validation
339
- return true if profile.valid?
340
-
341
- result.add_information(missing_validations: "Please check dry validations")
342
- result.add_errors(profile.errors.messages)
343
-
344
- false
345
- end
346
-
347
- def create
348
- profile.save
349
- end
350
-
351
- def send_email
352
- mailer.send_mail(profile: profile)
353
- end
354
-
355
- def output
356
- result.output = { model: profile }
357
- end
358
- end
359
- ```
360
-
361
- #### Call with valid parameters
362
-
363
- ```ruby
364
- Profile::Create.call(params: {
365
- first_name: :foo,
366
- last_name: :bar
367
- }, dependencies: {
368
- mailer: MyMailer,
369
- current_account: Account.find(1)
370
- })
371
-
372
- #<Opera::Operation::Result:0x0000560ebc9e7a98 @errors={}, @information={}, @executions=[:profile_schema, :build_record, :old_validation, :create, :send_email, :output], @output={:model=>#<Profile id: 41, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-14 19:15:12", updated_at: "2020-08-14 19:15:12", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
373
- ```
374
-
375
- #### Call with INVALID parameters
376
-
377
- ```ruby
378
- Profile::Create.call(params: {
379
- first_name: :foo
380
- }, dependencies: {
381
- mailer: MyMailer,
382
- current_account: Account.find(1)
383
- })
384
-
385
- #<Opera::Operation::Result:0x0000560ef76ba588 @errors={:last_name=>["can't be blank"]}, @information={:missing_validations=>"Please check dry validations"}, @executions=[:build_record, :old_validation]>
386
- ```
387
-
388
- ### Example with step that finishes execution
389
-
390
- ```ruby
391
- class Profile::Create < Opera::Operation::Base
392
- # DEPRECATED
393
- # context_accessor :profile
394
- context do
395
- attr_accessor :profile
396
- end
397
- # DEPRECATED
398
- # dependencies_reader :current_account, :mailer
399
- dependencies do
400
- attr_reader :current_account, :mailer
401
- end
402
-
403
- validate :profile_schema
404
-
405
- step :build_record
406
- step :create
407
- step :send_email
408
- step :output
409
-
410
- def profile_schema
411
- Dry::Validation.Schema do
412
- required(:first_name).filled
413
- end.call(params)
414
- end
415
-
416
- def build_record
417
- self.profile = current_account.profiles.build(params)
418
- self.profile.force_name_validation = true
419
- end
420
-
421
- def create
422
- self.profile = profile.save
423
- finish!
424
- end
425
-
426
- def send_email
427
- return true unless mailer
428
-
429
- mailer.send_mail(profile: profile)
430
- end
431
-
432
- def output
433
- result.output(model: profile)
434
- end
435
- end
436
- ```
437
-
438
- ##### Call
439
-
440
- ```ruby
441
- result = Profile::Create.call(params: {
442
- first_name: :foo,
443
- last_name: :bar
444
- }, dependencies: {
445
- current_account: Account.find(1)
446
- })
447
-
448
- #<Opera::Operation::Result:0x007fc2c59a8460 @errors={}, @information={}, @executions=[:profile_schema, :build_record, :create]>
449
- ```
450
-
451
- ### Failing transaction
452
-
453
- ```ruby
454
- class Profile::Create < Opera::Operation::Base
455
- configure do |config|
456
- config.transaction_class = Profile
457
- end
458
-
459
- # DEPRECATED
460
- # context_accessor :profile
461
- context do
462
- attr_accessor :profile
463
- end
464
- # DEPRECATED
465
- # dependencies_reader :current_account, :mailer
466
- dependencies do
467
- attr_reader :current_account, :mailer
468
- end
469
-
470
- validate :profile_schema
471
-
472
- transaction do
473
- step :create
474
- step :update
475
- end
476
-
477
- step :send_email
478
- step :output
479
-
480
- def profile_schema
481
- Dry::Validation.Schema do
482
- required(:first_name).filled
483
- end.call(params)
484
- end
485
-
486
- def create
487
- self.profile = current_account.profiles.create(params)
488
- end
489
-
490
- def update
491
- profile.update(example_attr: :Example)
492
- end
493
-
494
- def send_email
495
- return true unless mailer
10
+ ## Installation
496
11
 
497
- mailer.send_mail(profile: profile)
498
- end
12
+ Add to your Gemfile:
499
13
 
500
- def output
501
- result.output = { model: profile }
502
- end
503
- end
14
+ ```ruby
15
+ gem 'opera'
504
16
  ```
505
17
 
506
- #### Example with non-existing attribute
507
-
508
- ```ruby
509
- Profile::Create.call(params: {
510
- first_name: :foo,
511
- last_name: :bar
512
- }, dependencies: {
513
- mailer: MyMailer,
514
- current_account: Account.find(1)
515
- })
18
+ Then run `bundle install`.
516
19
 
517
- D, [2020-08-14T16:13:30.946466 #2504] DEBUG -- : Account Load (0.5ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
518
- D, [2020-08-14T16:13:30.960254 #2504] DEBUG -- : (0.2ms) BEGIN
519
- D, [2020-08-14T16:13:30.983981 #2504] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-14 16:13:30.982289"], ["updated_at", "2020-08-14 16:13:30.982289"], ["account_id", 1]]
520
- D, [2020-08-14T16:13:30.986233 #2504] DEBUG -- : (0.2ms) ROLLBACK
521
- D, [2020-08-14T16:13:30.988231 #2504] DEBUG -- : unknown attribute 'example_attr' for Profile. (ActiveModel::UnknownAttributeError)
522
- ```
20
+ > Requires Ruby >= 3.1. For Ruby 2.x use Opera 0.2.x.
523
21
 
524
- ### Passing transaction
22
+ ## Quick Start
525
23
 
526
24
  ```ruby
527
25
  class Profile::Create < Opera::Operation::Base
528
- configure do |config|
529
- config.transaction_class = Profile
530
- end
531
-
532
- # DEPRECATED
533
- # context_accessor :profile
534
26
  context do
535
27
  attr_accessor :profile
536
28
  end
537
- # DEPRECATED
538
- # dependencies_reader :current_account, :mailer
29
+
539
30
  dependencies do
540
31
  attr_reader :current_account, :mailer
541
32
  end
542
33
 
543
34
  validate :profile_schema
544
35
 
545
- transaction do
546
- step :create
547
- step :update
548
- end
549
-
36
+ step :create
550
37
  step :send_email
551
38
  step :output
552
39
 
@@ -560,14 +47,8 @@ class Profile::Create < Opera::Operation::Base
560
47
  self.profile = current_account.profiles.create(params)
561
48
  end
562
49
 
563
- def update
564
- profile.update(updated_at: 1.day.ago)
565
- end
566
-
567
50
  def send_email
568
- return true unless mailer
569
-
570
- mailer.send_mail(profile: profile)
51
+ mailer&.send_mail(profile: profile)
571
52
  end
572
53
 
573
54
  def output
@@ -576,481 +57,154 @@ class Profile::Create < Opera::Operation::Base
576
57
  end
577
58
  ```
578
59
 
579
- #### Example with updating timestamp
580
-
581
- ```ruby
582
- Profile::Create.call(params: {
583
- first_name: :foo,
584
- last_name: :bar
585
- }, dependencies: {
586
- mailer: MyMailer,
587
- current_account: Account.find(1)
588
- })
589
- D, [2020-08-17T12:10:44.842392 #2741] DEBUG -- : Account Load (0.7ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
590
- D, [2020-08-17T12:10:44.856964 #2741] DEBUG -- : (0.2ms) BEGIN
591
- D, [2020-08-17T12:10:44.881332 #2741] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-17 12:10:44.879684"], ["updated_at", "2020-08-17 12:10:44.879684"], ["account_id", 1]]
592
- D, [2020-08-17T12:10:44.886168 #2741] DEBUG -- : SQL (0.6ms) UPDATE "profiles" SET "updated_at" = $1 WHERE "profiles"."id" = $2 [["updated_at", "2020-08-16 12:10:44.883164"], ["id", 47]]
593
- D, [2020-08-17T12:10:44.898132 #2741] DEBUG -- : (10.3ms) COMMIT
594
- #<Opera::Operation::Result:0x0000556528f29058 @errors={}, @information={}, @executions=[:profile_schema, :create, :update, :send_email, :output], @output={:model=>#<Profile id: 47, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-17 12:10:44", updated_at: "2020-08-16 12:10:44", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
595
- ```
596
-
597
- ### Benchmark
598
-
599
60
  ```ruby
600
- class Profile::Create < Opera::Operation::Base
601
- # DEPRECATED
602
- # context_accessor :profile
603
- context do
604
- attr_accessor :profile
605
- end
606
- # DEPRECATED
607
- # dependencies_reader :current_account, :mailer
608
- dependencies do
609
- attr_reader :current_account, :mailer
610
- end
611
-
612
- validate :profile_schema
613
-
614
- benchmark :fast_section do
615
- step :create
616
- step :update
617
- end
618
-
619
- benchmark :slow_section do
620
- step :send_email
621
- step :output
622
- end
623
-
624
- def profile_schema
625
- Dry::Validation.Schema do
626
- required(:first_name).filled
627
- end.call(params)
628
- end
629
-
630
- def create
631
- self.profile = current_account.profiles.create(params)
632
- end
633
-
634
- def update
635
- profile.update(updated_at: 1.day.ago)
636
- end
637
-
638
- def send_email
639
- return true unless mailer
640
-
641
- mailer.send_mail(profile: profile)
642
- end
643
-
644
- def output
645
- result.output = { model: profile }
646
- end
647
- end
648
- ```
649
-
650
- #### Example with information (real and total) from benchmark
61
+ result = Profile::Create.call(
62
+ params: { first_name: "Jane", last_name: "Doe" },
63
+ dependencies: { current_account: Account.find(1), mailer: MyMailer }
64
+ )
651
65
 
652
- ```ruby
653
- Profile::Create.call(params: {
654
- first_name: :foo,
655
- last_name: :bar
656
- }, dependencies: {
657
- current_account: Account.find(1)
658
- })
659
- #<Opera::Operation::Result:0x007ff414a01238 @errors={}, @information={fast_section: {:real=>0.300013706088066e-05, :total=>0.0}, slow_section: {:real=>1.800013706088066e-05, :total=>0.0}}, @executions=[:profile_schema, :create, :update, :send_email, :output], @output={:model=>#<Profile id: 30, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-19 10:46:00", updated_at: "2020-08-18 10:46:00", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
66
+ result.success? # => true
67
+ result.output # => { model: #<Profile ...> }
660
68
  ```
661
69
 
662
- ### Success
70
+ ## Configuration
663
71
 
664
72
  ```ruby
665
- class Profile::Create < Opera::Operation::Base
666
- # DEPRECATED
667
- # context_accessor :profile
668
- context do
669
- attr_accessor :profile
670
- end
671
- # DEPRECATED
672
- # dependencies_reader :current_account, :mailer
673
- dependencies do
674
- attr_reader :current_account, :mailer
675
- end
676
-
677
- validate :profile_schema
678
-
679
- success :populate
680
-
681
- step :create
682
- step :update
683
-
684
- success do
685
- step :send_email
686
- step :output
687
- end
688
-
689
- def profile_schema
690
- Dry::Validation.Schema do
691
- required(:first_name).filled
692
- end.call(params)
693
- end
694
-
695
- def populate
696
- context[:attributes] = {}
697
- context[:valid] = false
698
- end
699
-
700
- def create
701
- self.profile = current_account.profiles.create(params)
702
- end
703
-
704
- def update
705
- profile.update(updated_at: 1.day.ago)
706
- end
707
-
708
- # NOTE: We can add an error in this step and it won't break the execution
709
- def send_email
710
- result.add_error('mailer', 'Missing dependency')
711
- mailer&.send_mail(profile: profile)
712
- end
713
-
714
- def output
715
- result.output = { model: context[:profile] }
716
- end
73
+ Opera::Operation::Config.configure do |config|
74
+ config.transaction_class = ActiveRecord::Base
75
+ config.transaction_method = :transaction # default
76
+ config.transaction_options = { requires_new: true } # optional
77
+ config.instrumentation_class = MyInstrumentationAdapter # optional
78
+ config.mode = :development # or :production
79
+ config.reporter = Rails.logger # optional
717
80
  end
718
81
  ```
719
82
 
720
- #### Example output for success block
83
+ Override per operation:
721
84
 
722
85
  ```ruby
723
- Profile::Create.call(params: {
724
- first_name: :foo,
725
- last_name: :bar
726
- }, dependencies: {
727
- current_account: Account.find(1)
728
- })
729
- #<Opera::Operation::Result:0x007fd0248e5638 @errors={"mailer"=>["Missing dependency"]}, @information={}, @executions=[:profile_schema, :populate, :create, :update, :send_email, :output], @output={:model=>#<Profile id: 40, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2019-01-03 12:21:35", updated_at: "2019-01-02 12:21:35", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
730
- ```
731
-
732
- ### Finish If
733
-
734
- ```ruby
735
- class Profile::Create < Opera::Operation::Base
736
- # DEPRECATED
737
- # context_accessor :profile
738
- context do
739
- attr_accessor :profile
740
- end
741
- # DEPRECATED
742
- # dependencies_reader :current_account, :mailer
743
- dependencies do
744
- attr_reader :current_account, :mailer
745
- end
746
-
747
- validate :profile_schema
748
-
749
- step :create
750
- finish_if :profile_create_only
751
- step :update
752
-
753
- success do
754
- step :send_email
755
- step :output
756
- end
757
-
758
- def profile_schema
759
- Dry::Validation.Schema do
760
- required(:first_name).filled
761
- end.call(params)
762
- end
763
-
764
- def create
765
- self.profile = current_account.profiles.create(params)
766
- end
767
-
768
- def profile_create_only
769
- dependencies[:create_only].present?
770
- end
771
-
772
- def update
773
- profile.update(updated_at: 1.day.ago)
774
- end
775
-
776
- # NOTE: We can add an error in this step and it won't break the execution
777
- def send_email
778
- result.add_error('mailer', 'Missing dependency')
779
- mailer&.send_mail(profile: profile)
780
- end
781
-
782
- def output
783
- result.output = { model: context[:profile] }
86
+ class MyOperation < Opera::Operation::Base
87
+ configure do |config|
88
+ config.transaction_class = Profile
89
+ config.reporter = Rollbar
784
90
  end
785
91
  end
786
92
  ```
787
93
 
788
- #### Example with information (real and total) from benchmark
94
+ Setting `mode: :production` skips storing execution traces for lower memory usage.
789
95
 
790
- ```ruby
791
- Profile::Create.call(params: {
792
- first_name: :foo,
793
- last_name: :bar
794
- }, dependencies: {
795
- create_only: true,
796
- current_account: Account.find(1)
797
- })
798
- #<Opera::Operation::Result:0x007fd0248e5638 @errors={}, @information={}, @executions=[:profile_schema, :create, :profile_create_only], @output={}>
799
- ```
96
+ ## Instrumentation
800
97
 
801
- ### Inner Operation
98
+ To instrument operations, create an adapter inheriting from `Opera::Operation::Instrumentation::Base`:
802
99
 
803
100
  ```ruby
804
- class Profile::Find < Opera::Operation::Base
805
- step :find
806
-
807
- def find
808
- result.output = Profile.find(params[:id])
101
+ class MyInstrumentation < Opera::Operation::Instrumentation::Base
102
+ def self.instrument(operation, name:, level:)
103
+ # level is :operation or :step
104
+ Datadog::Tracing.trace(name, service: :opera) { yield }
809
105
  end
810
106
  end
811
107
 
812
- class Profile::Create < Opera::Operation::Base
813
- validate :profile_schema
814
-
815
- operation :find
816
-
817
- step :create
818
-
819
- step :output
820
-
821
- def profile_schema
822
- Dry::Validation.Schema do
823
- optional(:id).filled
824
- end.call(params)
825
- end
826
-
827
- def find
828
- Profile::Find.call(params: params, dependencies: dependencies)
829
- end
830
-
831
- def create
832
- return if context[:find_output]
833
- puts 'not found'
834
- end
835
-
836
- def output
837
- result.output = { model: context[:find_output] }
838
- end
108
+ Opera::Operation::Config.configure do |config|
109
+ config.instrumentation_class = MyInstrumentation
839
110
  end
840
111
  ```
841
112
 
842
- #### Example with inner operation doing the find
113
+ ## DSL Reference
843
114
 
844
- ```ruby
845
- Profile::Create.call(params: {
846
- id: 1
847
- }, dependencies: {
848
- current_account: Account.find(1)
849
- })
850
- #<Opera::Operation::Result:0x007f99b25f0f20 @errors={}, @information={}, @executions=[:profile_schema, :find, :create, :output], @output={:model=>{:id=>1, :user_id=>1, :linkedin_uid=>nil, ...}}>
851
- ```
115
+ | Instruction | Description |
116
+ |---|---|
117
+ | `step :method` | Executes a method. Returns falsy to stop execution. |
118
+ | `validate :method` | Executes a method that must return `Dry::Validation::Result` or `Opera::Operation::Result`. Errors are accumulated -- all validations run even if some fail. |
119
+ | `transaction do ... end` | Wraps steps in a database transaction. Rolls back on error. |
120
+ | `success :method` or `success do ... end` | Like `step`, but a falsy return does **not** stop execution. Use for side effects. |
121
+ | `finish_if :method` | Stops execution (successfully) if the method returns truthy. |
122
+ | `operation :method` | Calls an inner operation. Must return `Opera::Operation::Result`. Propagates errors on failure. Output stored in `context[:<method>_output]`. |
123
+ | `operations :method` | Like `operation`, but the method must return an array of `Opera::Operation::Result`. |
124
+ | `within :method do ... end` | Wraps nested steps with a custom method that must `yield`. If it doesn't yield, nested steps are skipped. |
852
125
 
853
- ### Inner Operations
854
- Expects that method returns array of `Opera::Operation::Result`
126
+ ### Combining instructions
855
127
 
856
128
  ```ruby
857
- class Profile::Create < Opera::Operation::Base
858
- step :validate
859
- step :create
860
-
861
- def validate; end
862
-
863
- def create
864
- result.output = { model: "Profile #{Kernel.rand(100)}" }
865
- end
866
- end
129
+ class MyOperation < Opera::Operation::Base
130
+ validate :schema
867
131
 
868
- class Profile::CreateMultiple < Opera::Operation::Base
869
- operations :create_multiple
132
+ step :prepare
133
+ finish_if :already_done?
870
134
 
871
- step :output
135
+ transaction do
136
+ step :create
137
+ step :update
872
138
 
873
- def create_multiple
874
- (0..params[:number]).map do
875
- Profile::Create.call
139
+ within :read_from_replica do
140
+ step :check_duplicate
876
141
  end
877
142
  end
878
143
 
879
- def output
880
- result.output = context[:create_multiple_output]
144
+ success do
145
+ step :send_notification
146
+ step :log_audit
881
147
  end
882
- end
883
- ```
884
-
885
- ```ruby
886
- Profile::CreateMultiple.call(params: { number: 3 })
887
-
888
- #<Opera::Operation::Result:0x0000564189f38c90 @errors={}, @information={}, @executions=[{:create_multiple=>[[:validate, :create], [:validate, :create], [:validate, :create], [:validate, :create]]}, :output], @output=[{:model=>"Profile 1"}, {:model=>"Profile 7"}, {:model=>"Profile 69"}, {:model=>"Profile 92"}]>
889
- ```
890
-
891
- ## Opera::Operation::Result - Instance Methods
892
-
893
- Sometimes it may be useful to be able to create an instance of the `Result` with preset `output`.
894
- It can be handy especially in specs. Then just include it in the initializer:
895
-
896
- ```
897
- Opera::Operation::Result.new(output: 'success')
898
- ```
899
-
900
- >
901
- - success? - [true, false] - Return true if no errors
902
- - failure? - [true, false] - Return true if any error
903
- - output - [Anything] - Return Anything
904
- - output=(Anything) - Sets content of operation output
905
- - output! - Return Anything if Success, raise exception if Failure
906
- - add_error(key, value) - Adds new error message
907
- - add_errors(Hash) - Adds multiple error messages
908
- - add_information(Hash) - Adss new information - Useful informations for developers
909
-
910
- ## Opera::Operation::Base - Instance Methods
911
- >
912
- - context [Hash] - used to pass information between steps - only for internal usage
913
- - params [Hash] - immutable and received in call method
914
- - dependencies [Hash] - immutable and received in call method
915
- - finish! - this method interrupts the execution of steps after is invoked
916
-
917
- ## Opera::Operation::Base - Class Methods
918
-
919
- #### `context_reader`
920
-
921
- The `context_reader` helper method is designed to facilitate easy access to specified keys within a `context` hash. It dynamically defines a method that acts as a getter for the value associated with a specified key, simplifying data retrieval.
922
148
 
923
- #### Parameters
924
- **key (Symbol):** The key(s) for which the getter and setter methods are to be created. These symbols should correspond to keys in the context hash.
925
-
926
- **default (Proc, optional):** A lambda or proc that returns a default value for the key if it is not present in the context hash. This proc is lazily evaluated only when the getter is invoked and the key is not present in the hash.
927
-
928
- #### Usage
929
-
930
- **GOOD**
931
-
932
- ```ruby
933
- # USE context_reader to read steps outputs from the context hash
934
-
935
- context_reader :schema_output
936
-
937
- validate :schema # context = { schema_output: { id: 1 } }
938
- step :do_something
939
-
940
- def do_something
941
- puts schema_output # outputs: { id: 1 }
149
+ step :output
942
150
  end
943
151
  ```
944
152
 
945
- ```ruby
946
- # USE context_reader with 'default' option to provide default value when key is missing in the context hash
947
-
948
- context_reader :profile, default: -> { Profile.new }
949
-
950
- step :fetch_profile
951
- step :do_something
952
-
953
- def fetch_profile
954
- return if App.http_disabled?
955
-
956
- context[:profile] = ProfileFetcher.call
957
- end
958
-
959
- def update_profile
960
- profile.name = 'John'
961
- profile.save!
962
- end
963
- ```
153
+ ## Result API
964
154
 
965
- **BAD**
155
+ | Method | Returns | Description |
156
+ |---|---|---|
157
+ | `success?` | `Boolean` | `true` if no errors |
158
+ | `failure?` | `Boolean` | `true` if any errors |
159
+ | `output` | `Object` | The operation's return value |
160
+ | `output!` | `Object` | Returns output if success, raises `OutputError` if failure |
161
+ | `output=` | | Sets the output |
162
+ | `errors` | `Hash` | Accumulated error messages |
163
+ | `failures` | `Hash` | Alias for `errors` |
164
+ | `information` | `Hash` | Developer-facing metadata |
165
+ | `executions` | `Array` | Ordered list of executed steps (development mode only) |
166
+ | `add_error(key, value)` | | Adds a single error |
167
+ | `add_errors(hash)` | | Merges multiple errors |
168
+ | `add_information(hash)` | | Merges metadata |
966
169
 
967
170
  ```ruby
968
- # Using `context_reader` to create read-only methods that instantiate objects,
969
- # especially when these objects are not stored or updated in the `context` hash, is not recommended.
970
- # This approach can lead to confusion and misuse of the context hash,
971
- # as it suggests that the object might be part of the persistent state.
972
- context_reader :serializer, default: -> { ProfileSerializer.new }
973
-
974
- step :output
975
-
976
- def output
977
- self.result = serializer.to_json({...})
978
- end
979
-
980
-
981
- # A better practice is to use private methods to define read-only access to resources
982
- # that are instantiated on the fly and not intended for storage in any state context.
983
-
984
- step :output
985
-
986
- def output
987
- self.result = serializer.to_json({...})
988
- end
989
-
990
- private
991
-
992
- def serializer
993
- ProfileSerializer.new
994
- end
171
+ # Pre-set output (useful in specs)
172
+ Opera::Operation::Result.new(output: 'success')
995
173
  ```
996
- **Conclusion**
997
-
998
- For creating instance methods that are meant to be read-only and not stored within a context hash, defining these methods as private is a more suitable and clear approach compared to using context_reader with a default. This method ensures that transient dependencies remain well-encapsulated and are not confused with persistent application state.
999
-
1000
- ### `context|params|depenencies`
1001
174
 
1002
- The `context|params|depenencies` helper method is designed to enable easy access to and modification of values for specified keys within a `context` hash. This method dynamically defines both getter and setter methods for the designated keys, facilitating straightforward retrieval and update of values.
175
+ ## Operation Instance Methods
1003
176
 
1004
- #### attr_reader, attr_accessor Parameters
177
+ | Method | Description |
178
+ |---|---|
179
+ | `context` | Mutable `Hash` for passing data between steps |
180
+ | `params` | Immutable `Hash` received via `call` |
181
+ | `dependencies` | Immutable `Hash` received via `call` |
182
+ | `result` | The `Opera::Operation::Result` instance |
183
+ | `finish!` | Halts step execution (operation is still successful) |
1005
184
 
1006
- **key (Symbol):** The key(s) for which the getter and setter methods are to be created. These symbols will correspond to keys in the context hash.
185
+ ## Testing
1007
186
 
1008
- **default (Proc, optional):** A lambda or proc that returns a default value for the key if it is not present in the context hash. This proc is lazily evaluated only when the getter is invoked and the key is not present in the hash.
187
+ When using Opera inside a Rails engine, configure the transaction class in your test helper:
1009
188
 
1010
- #### Usage
1011
189
  ```ruby
1012
- context do
1013
- attr_accessor :profile
1014
- end
1015
-
1016
- step :fetch_profile
1017
- step :update_profile
1018
-
1019
- def fetch_profile
1020
- self.profile = ProfileFetcher.call # sets context[:profile]
1021
- end
1022
-
1023
- def update_profile
1024
- profile.update!(name: 'John') # reads profile from context[:profile]
190
+ # spec_helper.rb or rails_helper.rb
191
+ Opera::Operation::Config.configure do |config|
192
+ config.transaction_class = ActiveRecord::Base
1025
193
  end
1026
194
  ```
1027
195
 
1028
- ```ruby
1029
- context do
1030
- attr_accessor :profile, default: -> { Profile.new }
1031
- end
1032
- ```
196
+ ## Examples
1033
197
 
1034
- ```ruby
1035
- context do
1036
- attr_accessor :profile, :account
1037
- end
1038
- ```
198
+ Detailed examples with full input/output are available in the [`docs/examples/`](docs/examples/) directory:
1039
199
 
1040
- #### Other methods
1041
- >
1042
- - step(Symbol) - single instruction
1043
- - return [Truthly] - continue operation execution
1044
- - return [False] - stops operation execution
1045
- - operation(Symbol) - single instruction - requires to return Opera::Operation::Result object
1046
- - return [Opera::Operation::Result] - stops operation STEPS execution if failure
1047
- - validate(Symbol) - single dry-validations - requires to return Dry::Validation::Result object
1048
- - return [Dry::Validation::Result] - stops operation STEPS execution if any error but continue with other validations
1049
- - transaction(*Symbols) - list of instructions to be wrapped in transaction
1050
- - return [Truthly] - continue operation execution
1051
- - return [False] - stops operation execution and breaks transaction/do rollback
1052
- - call(params: Hash, dependencies: Hash?)
1053
- - return [Opera::Operation::Result]
200
+ - [Basic Operation](docs/examples/basic-operation.md)
201
+ - [Validations](docs/examples/validations.md)
202
+ - [Transactions](docs/examples/transactions.md)
203
+ - [Success Blocks](docs/examples/success-blocks.md)
204
+ - [Finish If](docs/examples/finish-if.md)
205
+ - [Inner Operations](docs/examples/inner-operations.md)
206
+ - [Within](docs/examples/within.md)
207
+ - [Context, Params & Dependencies](docs/examples/context-params-dependencies.md)
1054
208
 
1055
209
  ## Development
1056
210
 
@@ -1060,13 +214,8 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
1060
214
 
1061
215
  ## Contributing
1062
216
 
1063
- Bug reports and pull requests are welcome on GitHub at https://github.com/profinda/opera. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/opera/blob/master/CODE_OF_CONDUCT.md).
1064
-
217
+ Bug reports and pull requests are welcome on GitHub at https://github.com/profinda/opera. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/profinda/opera/blob/master/CODE_OF_CONDUCT.md).
1065
218
 
1066
219
  ## License
1067
220
 
1068
221
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
1069
-
1070
- ## Code of Conduct
1071
-
1072
- Everyone interacting in the Opera project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/profinda/opera/blob/master/CODE_OF_CONDUCT.md).