opera 0.5.0 → 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,673 +3,30 @@
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
- Simple DSL for services/interactions classes.
6
+ A lightweight DSL for building operations, services and interactions in Ruby. Zero runtime dependencies.
7
7
 
8
- Opera was born to mimic some of the philosophy of the dry gems but keeping the DSL simple.
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.
9
9
 
10
- 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.
11
-
12
- ## Installation
13
-
14
- Add this line to your application's Gemfile:
15
-
16
- ```ruby
17
- gem 'opera'
18
- ```
19
-
20
- And then execute:
21
-
22
- $ bundle install
23
-
24
- Or install it yourself as:
25
-
26
- $ gem install opera
27
-
28
- Note. If you are using Ruby 2.x please use Opera 0.2.x
29
-
30
- ## Configuration
31
-
32
- Opera is built to be used with or without Rails.
33
- Simply initialize the configuration and choose a custom logger and which library to use for implementing transactions.
34
-
35
- ```ruby
36
- Opera::Operation::Config.configure do |config|
37
- config.transaction_class = ActiveRecord::Base
38
- config.transaction_method = :transaction
39
- config.transaction_options = { requires_new: true, level: :step } # or level: :operation - default
40
- config.instrumentation_class = Datadog::Tracing
41
- config.instrumentation_method = :trace
42
- config.instrumentation_options = { service: :operation }
43
- config.mode = :development # Can be set to production too
44
- config.reporter = defined?(Rollbar) ? Rollbar : Rails.logger
45
- end
46
- ```
47
-
48
- You can later override this configuration in each Operation to have more granularity
49
-
50
- ## Usage
51
-
52
- Once opera gem is in your project you can start to build Operations
53
-
54
- ```ruby
55
- class A < Opera::Operation::Base
56
- configure do |config|
57
- config.transaction_class = Profile
58
- config.reporter = Rails.logger
59
- end
60
-
61
- success :populate
62
-
63
- operation :inner_operation
64
-
65
- validate :profile_schema
66
-
67
- transaction do
68
- step :create
69
- step :update
70
- step :destroy
71
- end
72
-
73
- validate do
74
- step :validate_object
75
- step :validate_relationships
76
- end
77
-
78
- success do
79
- step :send_mail
80
- step :report_to_audit_log
81
- end
82
-
83
- step :output
84
- end
85
- ```
86
-
87
- 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.
88
-
89
- ### Specs
90
-
91
- When using Opera::Operation inside an engine add the following
92
- configuration to your spec_helper.rb or rails_helper.rb:
93
-
94
- ```ruby
95
- Opera::Operation::Config.configure do |config|
96
- config.transaction_class = ActiveRecord::Base
97
- end
98
- ```
99
-
100
- Without this extra configuration you will receive:
101
-
102
- ```ruby
103
- NoMethodError:
104
- undefined method `transaction' for nil:NilClass
105
- ```
106
-
107
- ### Instrumentation
108
-
109
- When you want to easily instrument your operations you can add this to the opera config:
110
-
111
- ```ruby
112
- Rails.application.configure do
113
- config.x.instrumentation_class = Datadog::Tracing
114
- config.x.instrumentation_method = :trace
115
- config.x.instrumentation_options = { service: :opera }
116
- end
117
- ```
118
-
119
- You can also instrument individual operations by adding this to the operation config:
120
-
121
- ```ruby
122
- class A < Opera::Operation::Base
123
- configure do |config|
124
- config.instrumentation_class = Datadog::Tracing
125
- config.instrumentation_method = :trace
126
- config.instrumentation_options = { service: :opera, level: :step }
127
- end
128
-
129
- # steps
130
- end
131
- ```
132
-
133
- ### Content
134
-
135
- [Basic operation](#user-content-basic-operation)
136
-
137
- [Example with sanitizing parameters](#user-content-example-with-sanitizing-parameters)
138
-
139
- [Example operation with old validations](#user-content-example-operation-with-old-validations)
140
-
141
- [Failing transaction](#user-content-failing-transaction)
142
-
143
- [Passing transaction](#user-content-passing-transaction)
144
-
145
- [Success](#user-content-success)
146
-
147
- [Finish if](#user-content-finish-if)
148
-
149
- [Inner Operation](#user-content-inner-operation)
150
-
151
- [Inner Operations](#user-content-inner-operations)
152
-
153
- [Within](#user-content-within)
154
-
155
- ## Usage examples
156
-
157
- Some cases and example how to use new operations
158
-
159
- ### Basic operation
160
-
161
- ```ruby
162
- class Profile::Create < Opera::Operation::Base
163
- # DEPRECATED
164
- # context_accessor :profile
165
- context do
166
- attr_accessor :profile
167
- end
168
- # DEPRECATED
169
- # dependencies_reader :current_account, :mailer
170
- dependencies do
171
- attr_reader :current_account, :mailer
172
- end
173
-
174
- validate :profile_schema
175
-
176
- step :create
177
- step :send_email
178
- step :output
179
-
180
- def profile_schema
181
- Dry::Validation.Schema do
182
- required(:first_name).filled
183
- end.call(params)
184
- end
185
-
186
- def create
187
- self.profile = current_account.profiles.create(params)
188
- end
189
-
190
- def send_email
191
- mailer&.send_mail(profile: profile)
192
- end
193
-
194
- def output
195
- result.output = { model: profile }
196
- end
197
- end
198
- ```
199
-
200
- #### Call with valid parameters
201
-
202
- ```ruby
203
- Profile::Create.call(params: {
204
- first_name: :foo,
205
- last_name: :bar
206
- }, dependencies: {
207
- mailer: MyMailer,
208
- current_account: Account.find(1)
209
- })
210
-
211
- #<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: []>}>
212
- ```
213
-
214
- #### Call with INVALID parameters - missing first_name
215
-
216
- ```ruby
217
- Profile::Create.call(params: {
218
- last_name: :bar
219
- }, dependencies: {
220
- mailer: MyMailer,
221
- current_account: Account.find(1)
222
- })
223
-
224
- #<Opera::Operation::Result:0x0000562d3f635390 @errors={:first_name=>["is missing"]}, @information={}, @executions=[:profile_schema]>
225
- ```
226
-
227
- #### Call with MISSING dependencies
228
-
229
- ```ruby
230
- Profile::Create.call(params: {
231
- first_name: :foo,
232
- last_name: :bar
233
- }, dependencies: {
234
- current_account: Account.find(1)
235
- })
236
-
237
- #<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: []>}>
238
- ```
239
-
240
- ### Example with sanitizing parameters
241
-
242
- ```ruby
243
- class Profile::Create < Opera::Operation::Base
244
- # DEPRECATED
245
- # context_accessor :profile
246
- context do
247
- attr_accessor :profile
248
- end
249
- # DEPRECATED
250
- # dependencies_reader :current_account, :mailer
251
- dependencies do
252
- attr_reader :current_account, :mailer
253
- end
254
-
255
-
256
- validate :profile_schema
257
-
258
- step :create
259
- step :send_email
260
- step :output
261
-
262
- def profile_schema
263
- Dry::Validation.Schema do
264
- configure { config.input_processor = :sanitizer }
265
-
266
- required(:first_name).filled
267
- end.call(params)
268
- end
269
-
270
- def create
271
- self.profile = current_account.profiles.create(context[:profile_schema_output])
272
- end
273
-
274
- def send_email
275
- return true unless mailer
276
-
277
- mailer.send_mail(profile: profile)
278
- end
279
-
280
- def output
281
- result.output = { model: profile }
282
- end
283
- end
284
- ```
285
-
286
- ```ruby
287
- Profile::Create.call(params: {
288
- first_name: :foo,
289
- last_name: :bar
290
- }, dependencies: {
291
- mailer: MyMailer,
292
- current_account: Account.find(1)
293
- })
294
-
295
- # NOTE: Last name is missing in output model
296
- #<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: []>}>
297
- ```
298
-
299
- ### Example operation with old validations
300
-
301
- ```ruby
302
- class Profile::Create < Opera::Operation::Base
303
- # DEPRECATED
304
- # context_accessor :profile
305
- context do
306
- attr_accessor :profile
307
- end
308
- # DEPRECATED
309
- # dependencies_reader :current_account, :mailer
310
- dependencies do
311
- attr_reader :current_account, :mailer
312
- end
313
-
314
- validate :profile_schema
315
-
316
- step :build_record
317
- step :old_validation
318
- step :create
319
- step :send_email
320
- step :output
321
-
322
- def profile_schema
323
- Dry::Validation.Schema do
324
- required(:first_name).filled
325
- end.call(params)
326
- end
327
-
328
- def build_record
329
- self.profile = current_account.profiles.build(params)
330
- self.profile.force_name_validation = true
331
- end
332
-
333
- def old_validation
334
- return true if profile.valid?
335
-
336
- result.add_information(missing_validations: "Please check dry validations")
337
- result.add_errors(profile.errors.messages)
338
-
339
- false
340
- end
341
-
342
- def create
343
- profile.save
344
- end
345
-
346
- def send_email
347
- mailer.send_mail(profile: profile)
348
- end
349
-
350
- def output
351
- result.output = { model: profile }
352
- end
353
- end
354
- ```
355
-
356
- #### Call with valid parameters
357
-
358
- ```ruby
359
- Profile::Create.call(params: {
360
- first_name: :foo,
361
- last_name: :bar
362
- }, dependencies: {
363
- mailer: MyMailer,
364
- current_account: Account.find(1)
365
- })
366
-
367
- #<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: []>}>
368
- ```
369
-
370
- #### Call with INVALID parameters
371
-
372
- ```ruby
373
- Profile::Create.call(params: {
374
- first_name: :foo
375
- }, dependencies: {
376
- mailer: MyMailer,
377
- current_account: Account.find(1)
378
- })
379
-
380
- #<Opera::Operation::Result:0x0000560ef76ba588 @errors={:last_name=>["can't be blank"]}, @information={:missing_validations=>"Please check dry validations"}, @executions=[:build_record, :old_validation]>
381
- ```
382
-
383
- ### Example with step that finishes execution
384
-
385
- ```ruby
386
- class Profile::Create < Opera::Operation::Base
387
- # DEPRECATED
388
- # context_accessor :profile
389
- context do
390
- attr_accessor :profile
391
- end
392
- # DEPRECATED
393
- # dependencies_reader :current_account, :mailer
394
- dependencies do
395
- attr_reader :current_account, :mailer
396
- end
397
-
398
- validate :profile_schema
399
-
400
- step :build_record
401
- step :create
402
- step :send_email
403
- step :output
404
-
405
- def profile_schema
406
- Dry::Validation.Schema do
407
- required(:first_name).filled
408
- end.call(params)
409
- end
410
-
411
- def build_record
412
- self.profile = current_account.profiles.build(params)
413
- self.profile.force_name_validation = true
414
- end
415
-
416
- def create
417
- self.profile = profile.save
418
- finish!
419
- end
420
-
421
- def send_email
422
- return true unless mailer
423
-
424
- mailer.send_mail(profile: profile)
425
- end
426
-
427
- def output
428
- result.output(model: profile)
429
- end
430
- end
431
- ```
432
-
433
- ##### Call
434
-
435
- ```ruby
436
- result = Profile::Create.call(params: {
437
- first_name: :foo,
438
- last_name: :bar
439
- }, dependencies: {
440
- current_account: Account.find(1)
441
- })
442
-
443
- #<Opera::Operation::Result:0x007fc2c59a8460 @errors={}, @information={}, @executions=[:profile_schema, :build_record, :create]>
444
- ```
445
-
446
- ### Failing transaction
447
-
448
- ```ruby
449
- class Profile::Create < Opera::Operation::Base
450
- configure do |config|
451
- config.transaction_class = Profile
452
- end
453
-
454
- # DEPRECATED
455
- # context_accessor :profile
456
- context do
457
- attr_accessor :profile
458
- end
459
- # DEPRECATED
460
- # dependencies_reader :current_account, :mailer
461
- dependencies do
462
- attr_reader :current_account, :mailer
463
- end
464
-
465
- validate :profile_schema
466
-
467
- transaction do
468
- step :create
469
- step :update
470
- end
471
-
472
- step :send_email
473
- step :output
474
-
475
- def profile_schema
476
- Dry::Validation.Schema do
477
- required(:first_name).filled
478
- end.call(params)
479
- end
480
-
481
- def create
482
- self.profile = current_account.profiles.create(params)
483
- end
484
-
485
- def update
486
- profile.update(example_attr: :Example)
487
- end
488
-
489
- def send_email
490
- return true unless mailer
491
-
492
- mailer.send_mail(profile: profile)
493
- end
494
-
495
- def output
496
- result.output = { model: profile }
497
- end
498
- end
499
- ```
500
-
501
- #### Example with non-existing attribute
502
-
503
- ```ruby
504
- Profile::Create.call(params: {
505
- first_name: :foo,
506
- last_name: :bar
507
- }, dependencies: {
508
- mailer: MyMailer,
509
- current_account: Account.find(1)
510
- })
511
-
512
- 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]]
513
- D, [2020-08-14T16:13:30.960254 #2504] DEBUG -- : (0.2ms) BEGIN
514
- 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]]
515
- D, [2020-08-14T16:13:30.986233 #2504] DEBUG -- : (0.2ms) ROLLBACK
516
- D, [2020-08-14T16:13:30.988231 #2504] DEBUG -- : unknown attribute 'example_attr' for Profile. (ActiveModel::UnknownAttributeError)
517
- ```
518
-
519
- ### Passing transaction
520
-
521
- ```ruby
522
- class Profile::Create < Opera::Operation::Base
523
- configure do |config|
524
- config.transaction_class = Profile
525
- end
526
-
527
- # DEPRECATED
528
- # context_accessor :profile
529
- context do
530
- attr_accessor :profile
531
- end
532
- # DEPRECATED
533
- # dependencies_reader :current_account, :mailer
534
- dependencies do
535
- attr_reader :current_account, :mailer
536
- end
537
-
538
- validate :profile_schema
539
-
540
- transaction do
541
- step :create
542
- step :update
543
- end
544
-
545
- step :send_email
546
- step :output
547
-
548
- def profile_schema
549
- Dry::Validation.Schema do
550
- required(:first_name).filled
551
- end.call(params)
552
- end
553
-
554
- def create
555
- self.profile = current_account.profiles.create(params)
556
- end
557
-
558
- def update
559
- profile.update(updated_at: 1.day.ago)
560
- end
561
-
562
- def send_email
563
- return true unless mailer
564
-
565
- mailer.send_mail(profile: profile)
566
- end
567
-
568
- def output
569
- result.output = { model: profile }
570
- end
571
- end
572
- ```
573
-
574
- #### Example with updating timestamp
575
-
576
- ```ruby
577
- Profile::Create.call(params: {
578
- first_name: :foo,
579
- last_name: :bar
580
- }, dependencies: {
581
- mailer: MyMailer,
582
- current_account: Account.find(1)
583
- })
584
- 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]]
585
- D, [2020-08-17T12:10:44.856964 #2741] DEBUG -- : (0.2ms) BEGIN
586
- 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]]
587
- 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]]
588
- D, [2020-08-17T12:10:44.898132 #2741] DEBUG -- : (10.3ms) COMMIT
589
- #<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: []>}>
590
- ```
591
-
592
- ### Success
593
-
594
- ```ruby
595
- class Profile::Create < Opera::Operation::Base
596
- # DEPRECATED
597
- # context_accessor :profile
598
- context do
599
- attr_accessor :profile
600
- end
601
- # DEPRECATED
602
- # dependencies_reader :current_account, :mailer
603
- dependencies do
604
- attr_reader :current_account, :mailer
605
- end
606
-
607
- validate :profile_schema
608
-
609
- success :populate
610
-
611
- step :create
612
- step :update
613
-
614
- success do
615
- step :send_email
616
- step :output
617
- end
618
-
619
- def profile_schema
620
- Dry::Validation.Schema do
621
- required(:first_name).filled
622
- end.call(params)
623
- end
624
-
625
- def populate
626
- context[:attributes] = {}
627
- context[:valid] = false
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
10
+ ## Installation
637
11
 
638
- # NOTE: We can add an error in this step and it won't break the execution
639
- def send_email
640
- result.add_error('mailer', 'Missing dependency')
641
- mailer&.send_mail(profile: profile)
642
- end
12
+ Add to your Gemfile:
643
13
 
644
- def output
645
- result.output = { model: context[:profile] }
646
- end
647
- end
14
+ ```ruby
15
+ gem 'opera'
648
16
  ```
649
17
 
650
- #### Example output for success block
18
+ Then run `bundle install`.
651
19
 
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: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: []>}>
660
- ```
20
+ > Requires Ruby >= 3.1. For Ruby 2.x use Opera 0.2.x.
661
21
 
662
- ### Finish If
22
+ ## Quick Start
663
23
 
664
24
  ```ruby
665
25
  class Profile::Create < Opera::Operation::Base
666
- # DEPRECATED
667
- # context_accessor :profile
668
26
  context do
669
27
  attr_accessor :profile
670
28
  end
671
- # DEPRECATED
672
- # dependencies_reader :current_account, :mailer
29
+
673
30
  dependencies do
674
31
  attr_reader :current_account, :mailer
675
32
  end
@@ -677,13 +34,8 @@ class Profile::Create < Opera::Operation::Base
677
34
  validate :profile_schema
678
35
 
679
36
  step :create
680
- finish_if :profile_create_only
681
- step :update
682
-
683
- success do
684
- step :send_email
685
- step :output
686
- end
37
+ step :send_email
38
+ step :output
687
39
 
688
40
  def profile_schema
689
41
  Dry::Validation.Schema do
@@ -695,466 +47,164 @@ class Profile::Create < Opera::Operation::Base
695
47
  self.profile = current_account.profiles.create(params)
696
48
  end
697
49
 
698
- def profile_create_only
699
- dependencies[:create_only].present?
700
- end
701
-
702
- def update
703
- profile.update(updated_at: 1.day.ago)
704
- end
705
-
706
- # NOTE: We can add an error in this step and it won't break the execution
707
50
  def send_email
708
- result.add_error('mailer', 'Missing dependency')
709
51
  mailer&.send_mail(profile: profile)
710
52
  end
711
53
 
712
54
  def output
713
- result.output = { model: context[:profile] }
55
+ result.output = { model: profile }
714
56
  end
715
57
  end
716
58
  ```
717
59
 
718
60
  ```ruby
719
- Profile::Create.call(params: {
720
- first_name: :foo,
721
- last_name: :bar
722
- }, dependencies: {
723
- create_only: true,
724
- current_account: Account.find(1)
725
- })
726
- #<Opera::Operation::Result:0x007fd0248e5638 @errors={}, @information={}, @executions=[:profile_schema, :create, :profile_create_only], @output={}>
727
- ```
728
-
729
- ### Inner Operation
730
-
731
- ```ruby
732
- class Profile::Find < Opera::Operation::Base
733
- step :find
734
-
735
- def find
736
- result.output = Profile.find(params[:id])
737
- end
738
- end
739
-
740
- class Profile::Create < Opera::Operation::Base
741
- validate :profile_schema
742
-
743
- operation :find
744
-
745
- step :create
746
-
747
- step :output
748
-
749
- def profile_schema
750
- Dry::Validation.Schema do
751
- optional(:id).filled
752
- end.call(params)
753
- end
61
+ result = Profile::Create.call(
62
+ params: { first_name: "Jane", last_name: "Doe" },
63
+ dependencies: { current_account: Account.find(1), mailer: MyMailer }
64
+ )
754
65
 
755
- def find
756
- Profile::Find.call(params: params, dependencies: dependencies)
757
- end
758
-
759
- def create
760
- return if context[:find_output]
761
- puts 'not found'
762
- end
763
-
764
- def output
765
- result.output = { model: context[:find_output] }
766
- end
767
- end
66
+ result.success? # => true
67
+ result.output # => { model: #<Profile ...> }
768
68
  ```
769
69
 
770
- #### Example with inner operation doing the find
70
+ ## Configuration
771
71
 
772
72
  ```ruby
773
- Profile::Create.call(params: {
774
- id: 1
775
- }, dependencies: {
776
- current_account: Account.find(1)
777
- })
778
- #<Opera::Operation::Result:0x007f99b25f0f20 @errors={}, @information={}, @executions=[:profile_schema, :find, :create, :output], @output={:model=>{:id=>1, :user_id=>1, :linkedin_uid=>nil, ...}}>
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
80
+ end
779
81
  ```
780
82
 
781
- ### Inner Operations
782
-
783
- Expects that method returns array of `Opera::Operation::Result`
83
+ Override per operation:
784
84
 
785
85
  ```ruby
786
- class Profile::Create < Opera::Operation::Base
787
- step :validate
788
- step :create
789
-
790
- def validate; end
791
-
792
- def create
793
- result.output = { model: "Profile #{Kernel.rand(100)}" }
794
- end
795
- end
796
-
797
- class Profile::CreateMultiple < Opera::Operation::Base
798
- operations :create_multiple
799
-
800
- step :output
801
-
802
- def create_multiple
803
- (0..params[:number]).map do
804
- Profile::Create.call
805
- end
806
- end
807
-
808
- def output
809
- result.output = context[:create_multiple_output]
86
+ class MyOperation < Opera::Operation::Base
87
+ configure do |config|
88
+ config.transaction_class = Profile
89
+ config.reporter = Rollbar
810
90
  end
811
91
  end
812
92
  ```
813
93
 
814
- ```ruby
815
- Profile::CreateMultiple.call(params: { number: 3 })
816
-
817
- #<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"}]>
818
- ```
94
+ Setting `mode: :production` skips storing execution traces for lower memory usage.
819
95
 
820
- ### Within
96
+ ## Instrumentation
821
97
 
822
- `within` wraps one or more steps with a method you define on the operation. The method must `yield` to execute the nested steps. If it does not yield, the nested steps are skipped. Normal break conditions (errors, `finish!`) still apply inside the block.
98
+ To instrument operations, create an adapter inheriting from `Opera::Operation::Instrumentation::Base`:
823
99
 
824
100
  ```ruby
825
- class Profile::Create < Opera::Operation::Base
826
- context do
827
- attr_accessor :profile
828
- end
829
-
830
- dependencies do
831
- attr_reader :current_account
832
- end
833
-
834
- step :build
835
-
836
- within :read_from_replica do
837
- step :check_duplicate
838
- step :validate_quota
839
- end
840
-
841
- step :create
842
- step :output
843
-
844
- def build
845
- self.profile = current_account.profiles.build(params)
846
- end
847
-
848
- def check_duplicate
849
- result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email])
850
- end
851
-
852
- def validate_quota
853
- result.add_error(:base, 'quota exceeded') if current_account.profiles.count >= 100
854
- end
855
-
856
- def create
857
- profile.save!
858
- end
859
-
860
- def output
861
- result.output = { model: profile }
862
- end
863
-
864
- private
865
-
866
- def read_from_replica(&block)
867
- ActiveRecord::Base.connected_to(role: :reading, &block)
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 }
868
105
  end
869
106
  end
870
- ```
871
-
872
- `within`-method can also be used inline inside any step method when you need the wrapper for only part of that method's logic:
873
-
874
- ```ruby
875
- def some_step
876
- value = read_from_replica { Profile.count }
877
- result.output = { count: value }
878
- end
879
107
 
880
- private
881
-
882
- def read_from_replica(&block)
883
- ActiveRecord::Base.connected_to(role: :reading, &block)
108
+ Opera::Operation::Config.configure do |config|
109
+ config.instrumentation_class = MyInstrumentation
884
110
  end
885
111
  ```
886
112
 
887
- #### Mixing step and operation inside within
888
-
889
- `within` can wrap any combination of `step` and `operation` instructions. All of them execute inside the wrapper, and their outputs are available in context afterwards as usual.
890
-
891
- ```ruby
892
- class Profile::Create < Opera::Operation::Base
893
- context do
894
- attr_accessor :profile
895
- end
896
-
897
- dependencies do
898
- attr_reader :current_account, :quota_checker
899
- end
900
-
901
- within :read_from_replica do
902
- step :check_duplicate
903
- operation :fetch_quota
904
- end
905
-
906
- step :create
907
- step :output
908
-
909
- def check_duplicate
910
- result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email])
911
- end
912
-
913
- def fetch_quota
914
- quota_checker.call(params: params)
915
- end
916
-
917
- def create
918
- self.profile = current_account.profiles.create(params)
919
- end
920
-
921
- def output
922
- result.output = { model: profile, quota: context[:fetch_quota_output] }
923
- end
924
-
925
- private
926
-
927
- def read_from_replica(&block)
928
- ActiveRecord::Base.connected_to(role: :reading, &block)
929
- end
930
- end
931
- ```
113
+ ## DSL Reference
932
114
 
933
- #### Nesting within inside a transaction
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. |
934
125
 
935
- `within` can be placed inside a `transaction` block alongside other instructions. If any step or operation inside `within` fails, the error propagates up and the transaction is rolled back as normal.
126
+ ### Combining instructions
936
127
 
937
128
  ```ruby
938
- class Profile::Create < Opera::Operation::Base
939
- configure do |config|
940
- config.transaction_class = ActiveRecord::Base
941
- end
942
-
943
- context do
944
- attr_accessor :profile
945
- end
129
+ class MyOperation < Opera::Operation::Base
130
+ validate :schema
946
131
 
947
- dependencies do
948
- attr_reader :current_account, :quota_checker, :audit_logger
949
- end
132
+ step :prepare
133
+ finish_if :already_done?
950
134
 
951
135
  transaction do
136
+ step :create
137
+ step :update
138
+
952
139
  within :read_from_replica do
953
140
  step :check_duplicate
954
- operation :fetch_quota
955
141
  end
956
- operation :write_audit_log
957
- end
958
-
959
- step :output
960
-
961
- def check_duplicate
962
- result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email])
963
- end
964
-
965
- def fetch_quota
966
- quota_checker.call(params: params)
967
- end
968
-
969
- def write_audit_log
970
- audit_logger.call(params: params)
971
- end
972
-
973
- def output
974
- result.output = { quota: context[:fetch_quota_output] }
975
142
  end
976
143
 
977
- private
978
-
979
- def read_from_replica(&block)
980
- ActiveRecord::Base.connected_to(role: :reading, &block)
144
+ success do
145
+ step :send_notification
146
+ step :log_audit
981
147
  end
982
- end
983
- ```
984
-
985
- ## Opera::Operation::Result - Instance Methods
986
-
987
- Sometimes it may be useful to be able to create an instance of the `Result` with preset `output`.
988
- It can be handy especially in specs. Then just include it in the initializer:
989
148
 
990
- ```
991
- Opera::Operation::Result.new(output: 'success')
992
- ```
993
-
994
- >
995
-
996
- - success? - [true, false] - Return true if no errors
997
- - failure? - [true, false] - Return true if any error
998
- - output - [Anything] - Return Anything
999
- - output=(Anything) - Sets content of operation output
1000
- - output! - Return Anything if Success, raise exception if Failure
1001
- - add_error(key, value) - Adds new error message
1002
- - add_errors(Hash) - Adds multiple error messages
1003
- - add_information(Hash) - Adss new information - Useful informations for developers
1004
-
1005
- ## Opera::Operation::Base - Instance Methods
1006
-
1007
- >
1008
-
1009
- - context [Hash] - used to pass information between steps - only for internal usage
1010
- - params [Hash] - immutable and received in call method
1011
- - dependencies [Hash] - immutable and received in call method
1012
- - finish! - this method interrupts the execution of steps after is invoked
1013
-
1014
- ## Opera::Operation::Base - Class Methods
1015
-
1016
- #### `context_reader`
1017
-
1018
- 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.
1019
-
1020
- #### Parameters
1021
-
1022
- **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.
1023
-
1024
- **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.
1025
-
1026
- #### Usage
1027
-
1028
- **GOOD**
1029
-
1030
- ```ruby
1031
- # USE context_reader to read steps outputs from the context hash
1032
-
1033
- context_reader :schema_output
1034
-
1035
- validate :schema # context = { schema_output: { id: 1 } }
1036
- step :do_something
1037
-
1038
- def do_something
1039
- puts schema_output # outputs: { id: 1 }
149
+ step :output
1040
150
  end
1041
151
  ```
1042
152
 
1043
- ```ruby
1044
- # USE context_reader with 'default' option to provide default value when key is missing in the context hash
1045
-
1046
- context_reader :profile, default: -> { Profile.new }
153
+ ## Result API
1047
154
 
1048
- step :fetch_profile
1049
- step :do_something
1050
-
1051
- def fetch_profile
1052
- return if App.http_disabled?
1053
-
1054
- context[:profile] = ProfileFetcher.call
1055
- end
1056
-
1057
- def update_profile
1058
- profile.name = 'John'
1059
- profile.save!
1060
- end
1061
- ```
1062
-
1063
- **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 |
1064
169
 
1065
170
  ```ruby
1066
- # Using `context_reader` to create read-only methods that instantiate objects,
1067
- # especially when these objects are not stored or updated in the `context` hash, is not recommended.
1068
- # This approach can lead to confusion and misuse of the context hash,
1069
- # as it suggests that the object might be part of the persistent state.
1070
- context_reader :serializer, default: -> { ProfileSerializer.new }
1071
-
1072
- step :output
1073
-
1074
- def output
1075
- self.result = serializer.to_json({...})
1076
- end
1077
-
1078
-
1079
- # A better practice is to use private methods to define read-only access to resources
1080
- # that are instantiated on the fly and not intended for storage in any state context.
1081
-
1082
- step :output
1083
-
1084
- def output
1085
- self.result = serializer.to_json({...})
1086
- end
1087
-
1088
- private
1089
-
1090
- def serializer
1091
- ProfileSerializer.new
1092
- end
171
+ # Pre-set output (useful in specs)
172
+ Opera::Operation::Result.new(output: 'success')
1093
173
  ```
1094
174
 
1095
- **Conclusion**
1096
-
1097
- 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.
1098
-
1099
- ### `context|params|depenencies`
1100
-
1101
- 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.
1102
-
1103
- #### attr_reader, attr_accessor Parameters
1104
-
1105
- **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.
175
+ ## Operation Instance Methods
1106
176
 
1107
- **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.
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) |
1108
184
 
1109
- #### Usage
185
+ ## Testing
1110
186
 
1111
- ```ruby
1112
- context do
1113
- attr_accessor :profile
1114
- end
1115
-
1116
- step :fetch_profile
1117
- step :update_profile
1118
-
1119
- def fetch_profile
1120
- self.profile = ProfileFetcher.call # sets context[:profile]
1121
- end
1122
-
1123
- def update_profile
1124
- profile.update!(name: 'John') # reads profile from context[:profile]
1125
- end
1126
- ```
187
+ When using Opera inside a Rails engine, configure the transaction class in your test helper:
1127
188
 
1128
189
  ```ruby
1129
- context do
1130
- attr_accessor :profile, default: -> { Profile.new }
1131
- end
1132
- ```
1133
-
1134
- ```ruby
1135
- context do
1136
- attr_accessor :profile, :account
190
+ # spec_helper.rb or rails_helper.rb
191
+ Opera::Operation::Config.configure do |config|
192
+ config.transaction_class = ActiveRecord::Base
1137
193
  end
1138
194
  ```
1139
195
 
1140
- #### Other methods
196
+ ## Examples
1141
197
 
1142
- >
198
+ Detailed examples with full input/output are available in the [`docs/examples/`](docs/examples/) directory:
1143
199
 
1144
- - step(Symbol) - single instruction
1145
- - return [Truthly] - continue operation execution
1146
- - return [False] - stops operation execution
1147
- - operation(Symbol) - single instruction - requires to return Opera::Operation::Result object
1148
- - return [Opera::Operation::Result] - stops operation STEPS execution if failure
1149
- - validate(Symbol) - single dry-validations - requires to return Dry::Validation::Result object
1150
- - return [Dry::Validation::Result] - stops operation STEPS execution if any error but continue with other validations
1151
- - transaction(*Symbols) - list of instructions to be wrapped in transaction
1152
- - return [Truthly] - continue operation execution
1153
- - return [False] - stops operation execution and breaks transaction/do rollback
1154
- - within(Symbol, &block) - wraps nested steps with a custom method that must yield
1155
- - the named method receives a block and must yield to execute the nested steps
1156
- - call(params: Hash, dependencies: Hash?)
1157
- - 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)
1158
208
 
1159
209
  ## Development
1160
210
 
@@ -1164,12 +214,8 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
1164
214
 
1165
215
  ## Contributing
1166
216
 
1167
- 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).
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).
1168
218
 
1169
219
  ## License
1170
220
 
1171
221
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
1172
-
1173
- ## Code of Conduct
1174
-
1175
- 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).