plumb 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 573f2aeb395f26fdc392bf7741d9874fb0318fabc4b8af6f1d1f7a8991f50100
4
- data.tar.gz: 464c7aaa1b6dcbae0195b2bf51103438f3cd8a7f91593af0640a55f6f568e011
3
+ metadata.gz: db1a6e5f70bf36e91d053ff465e9f566cd4371e4620dac88aea6f028918311a8
4
+ data.tar.gz: 13e986c5a7815c3ecbdf6f1f6cabb4d9341b88421d8048a7e7182d9b638e1632
5
5
  SHA512:
6
- metadata.gz: 76fa4ec0bbed8a7e2c33537c7079ce47b7f4cdc693c2d61b4218980b36e17d2f57cd2cc80229c3bd91f2024dbcd706c4fd8846a3bc7dd7a730146f2417a6e882
7
- data.tar.gz: '03048bc44d4d3f0d9fca72d312cca9b679a2ff61e1026593c63fcbe727f550867862f43cecbdf3de93de756a5b577ff9e5f004c2f8c11e42985f4c23f9b0963d'
6
+ metadata.gz: 540cb16d4ab114931dad7b278578428357341e5318f5371a40d22b6d53714fe71290cb66892725316faf2060e5a1ca8e4c318951e9dc8eeb7839eac2a38d4800
7
+ data.tar.gz: 6404ab512cb061af57be9d7b3e41dfb967e3e651819f4fb540a6e018f55a5ab18a976649fe891338181f7e769b9efb0a29df1e8dd0586471b14ea0b7b04a511d
data/.rubocop.yml CHANGED
@@ -1,3 +1,5 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.2.2
1
3
  Style/CaseEquality:
2
4
  Enabled: false
3
5
  Style/LambdaCall:
data/README.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Plumb
2
2
 
3
- Composable data validation and coercion in Ruby. WiP.
3
+ **This library is work in progress!**
4
+
5
+ Composable data validation, coercion and processing in Ruby. Takes over from https://github.com/ismasan/parametric
6
+
7
+ This library takes ideas from the excellent https://dry-rb.org ecosystem, with some of the features offered by Dry-Types, Dry-Schema, Dry-Struct. However, I'm aiming at a subset of the functionality with a (hopefully) smaller API surface and fewer concepts, focusing on lessons learned after using Parametric in production for many years.
8
+
9
+ If you're after raw performance and versatility I strongly recommend you use the Dry gems.
10
+
11
+ For a description of the core architecture you can read [this article](https://ismaelcelis.com/posts/composable-pipelines-in-ruby/).
4
12
 
5
13
  ## Installation
6
14
 
@@ -18,7 +26,7 @@ module Types
18
26
  include Plumb::Types
19
27
 
20
28
  # Define your own types
21
- Email = String[/&/]
29
+ Email = String[/@/]
22
30
  end
23
31
 
24
32
  # Use them
@@ -32,7 +40,6 @@ result.errors # ""
32
40
  ```
33
41
 
34
42
 
35
-
36
43
  ### `#resolve(value) => Result`
37
44
 
38
45
  `#resolve` takes an input value and returns a `Result::Valid` or `Result::Invalid`
@@ -162,7 +169,42 @@ StringToInt = Types::String.transform(Integer, &:to_i)
162
169
  StringToInteger.parse('10') # => 10
163
170
  ```
164
171
 
172
+ ### `#invoke`
165
173
 
174
+ `#invoke` builds a Step that will invoke one or more methods on the value.
175
+
176
+ ```ruby
177
+ StringToInt = Types::String.invoke(:to_i)
178
+ StringToInt.parse('100') # 100
179
+
180
+ FilteredHash = Types::Hash.invoke(:except, :foo, :bar)
181
+ FilteredHash.parse(foo: 1, bar: 2, name: 'Joe') # { name: 'Joe' }
182
+
183
+ # It works with blocks
184
+ Evens = Types::Array[Integer].invoke(:filter, &:even?)
185
+ Evens.parse([1,2,3,4,5]) # [2, 4]
186
+
187
+ # Same as
188
+ Evens = Types::Array[Integer].transform(Array) {|arr| arr.filter(&:even?) }
189
+ ```
190
+
191
+ Passing an array of Symbol method names will build a chain of invocations.
192
+
193
+ ```ruby
194
+ UpcaseToSym = Types::String.invoke(%i[downcase to_sym])
195
+ UpcaseToSym.parse('FOO_BAR') # :foo_bar
196
+ ```
197
+
198
+ That that, as opposed to `#transform`, this modified does not register a type in `#metadata[:type]`, which can be valuable for introspection or documentation (ex. JSON Schema).
199
+
200
+ Also, there's no definition-time checks that the method names are actually supported by the input values.
201
+
202
+ ```ruby
203
+ type = Types::Array.invoke(:strip) # This is fine here
204
+ type.parse([1, 2]) # raises NoMethodError because Array doesn't respond to #strip
205
+ ```
206
+
207
+ Use with caution.
166
208
 
167
209
  ### `#default`
168
210
 
@@ -178,7 +220,7 @@ Note that this is syntax sugar for:
178
220
 
179
221
  ```ruby
180
222
  # A String, or if it's Undefined pipe to a static string value.
181
- str = Types::String | (Types::Undefined >> 'nope'.freeze)
223
+ str = Types::String | (Types::Undefined >> Types::Static['nope'.freeze])
182
224
  ```
183
225
 
184
226
  Meaning that you can compose your own semantics for a "default" value.
@@ -186,7 +228,7 @@ Meaning that you can compose your own semantics for a "default" value.
186
228
  Example when you want to apply a default when the given value is `nil`.
187
229
 
188
230
  ```ruby
189
- str = Types::String | (Types::Nil >> 'nope'.freeze)
231
+ str = Types::String | (Types::Nil >> Types::Static['nope'.freeze])
190
232
 
191
233
  str.parse(nil) # 'nope'
192
234
  str.parse('yup') # 'yup'
@@ -195,7 +237,7 @@ str.parse('yup') # 'yup'
195
237
  Same if you want to apply a default to several cases.
196
238
 
197
239
  ```ruby
198
- str = Types::String | ((Types::Nil | Types::Undefined) >> 'nope'.freeze)
240
+ str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
199
241
  ```
200
242
 
201
243
 
@@ -251,25 +293,23 @@ UserType.parse('Joe') # #<data User name="Joe">
251
293
  It takes an argument for a custom factory method on the object constructor.
252
294
 
253
295
  ```ruby
254
- class User
255
- def self.create(attrs)
256
- new(attrs)
257
- end
258
- end
296
+ # https://github.com/RubyMoney/monetize
297
+ require 'monetize'
259
298
 
260
- UserType = Types::String.build(User, :create)
299
+ StringToMoney = Types::String.build(Monetize, :parse)
300
+ money = StringToMoney.parse('£10,300.00') # #<Money fractional:1030000 currency:GBP>
261
301
  ```
262
302
 
263
303
  You can also pass a block
264
304
 
265
305
  ```ruby
266
- UserType = Types::String.build(User) { |name| User.new(name) }
306
+ StringToMoney = Types::String.build(Money) { |value| Monetize.parse(value) }
267
307
  ```
268
308
 
269
309
  Note that this case is identical to `#transform` with a block.
270
310
 
271
311
  ```ruby
272
- UserType = Types::String.transform(User) { |name| User.new(name) }
312
+ StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
273
313
  ```
274
314
 
275
315
 
@@ -350,7 +390,7 @@ Company = Types::Hash[
350
390
  result = Company.resolve(
351
391
  name: 'ACME',
352
392
  employees: [
353
- { name: 'Joe', age: 40, role: 'product' },
393
+ { name: 'Joe', age: 40, role: 'product' },
354
394
  { name: 'Joan', age: 38, role: 'engineer' }
355
395
  ]
356
396
  )
@@ -366,6 +406,66 @@ result.valid? # false
366
406
  result.errors[:employees][0][:age] # ["must be a Numeric"]
367
407
  ```
368
408
 
409
+ Note that you can use primitives as hash field definitions.
410
+
411
+ ```ruby
412
+ User = Types::Hash[name: String, age: Integer]
413
+ ```
414
+
415
+ Or to validate specific values:
416
+
417
+ ```ruby
418
+ Joe = Types::Hash[name: 'Joe', age: Integer]
419
+ ```
420
+
421
+ Or to validate against any `#===` interface:
422
+
423
+ ```ruby
424
+ Adult = Types::Hash[name: String, age: (18..)]
425
+ # Same as
426
+ Adult = Types::Hash[name: Types::String, age: Types::Integer[18..]]
427
+ ```
428
+
429
+ If you want to validate literal values, pass a `Types::Value`
430
+
431
+ ```ruby
432
+ Settings = Types::Hash[age_range: Types::Value[18..]]
433
+
434
+ Settings.parse(age_range: (18..)) # Valid
435
+ Settings.parse(age_range: (20..30)) # Invalid
436
+ ```
437
+
438
+ A `Types::Static` value will always resolve successfully to that value, regardless of the original payload.
439
+
440
+ ```ruby
441
+ User = Types::Hash[name: Types::Static['Joe'], age: Integer]
442
+ User.parse(name: 'Rufus', age: 34) # Valid {name: 'Joe', age: 34}
443
+ ```
444
+
445
+ #### Optional keys
446
+
447
+ Keys suffixed with `?` are marked as optional and its values will only be validated and coerced if the key is present in the input hash.
448
+
449
+ ```ruby
450
+ User = Types::Hash[
451
+ age?: Integer,
452
+ name: String
453
+ ]
454
+
455
+ User.parse(age: 20, name: 'Joe') # => Valid { age: 20, name: 'Joe' }
456
+ User.parse(age: '20', name: 'Joe') # => Invalid, :age is not an Integer
457
+ User.parse(name: 'Joe') #=> Valid { name: 'Joe' }
458
+ ```
459
+
460
+ Note that defaults are not applied to optional keys that are missing.
461
+
462
+ ```ruby
463
+ Types::Hash[
464
+ age?: Types::Integer.default(10), # does not apply default if key is missing
465
+ name: Types::String.default('Joe') # does apply default if key is missing.
466
+ ]
467
+ ```
468
+
369
469
 
370
470
 
371
471
  #### Merging hash definitions
@@ -394,9 +494,11 @@ intersection = User & Employee # Hash[:name]
394
494
 
395
495
  Use `#tagged_by` to resolve what definition to use based on the value of a common key.
396
496
 
497
+ Key used as index must be a `Types::Static`
498
+
397
499
  ```ruby
398
- NameUpdatedEvent = Types::Hash[type: 'name_updated', name: Types::String]
399
- AgeUpdatedEvent = Types::Hash[type: 'age_updated', age: Types::Integer]
500
+ NameUpdatedEvent = Types::Hash[type: Types::Static['name_updated'], name: Types::String]
501
+ AgeUpdatedEvent = Types::Hash[type: Types::Static['age_updated'], age: Types::Integer]
400
502
 
401
503
  Events = Types::Hash.tagged_by(
402
504
  :type,
@@ -409,6 +511,48 @@ Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definiti
409
511
 
410
512
 
411
513
 
514
+ #### `Types::Hash#inclusive`
515
+
516
+ Use `#inclusive` to preserve input keys not defined in the hash schema.
517
+
518
+ ```ruby
519
+ hash = Types::Hash[age: Types::Lax::Integer].inclusive
520
+
521
+ # Only :age, is coerced and validated, all other keys are preserved as-is
522
+ hash.parse(age: '30', name: 'Joe', last_name: 'Bloggs') # { age: 30, name: 'Joe', last_name: 'Bloggs' }
523
+ ```
524
+
525
+ This can be useful if you only care about validating some fields, or to assemble different front and back hashes. For example a client-facing one that validates JSON or form data, and a backend one that runs further coercions or domain validations on some keys.
526
+
527
+ ```ruby
528
+ # Front-end definition does structural validation
529
+ Front = Types::Hash[price: Integer, name: String, category: String]
530
+
531
+ # Turn an Integer into a Money instance
532
+ IntToMoney = Types::Integer.build(Money)
533
+
534
+ # Backend definition turns :price into a Money object, leaves other keys as-is
535
+ Back = Types::Hash[price: IntToMoney].inclusive
536
+
537
+ # Compose the pipeline
538
+ InputHandler = Front >> Back
539
+
540
+ InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
541
+ # => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: 'smartphone' }
542
+ ```
543
+
544
+ #### `Types::Hash#filtered`
545
+
546
+ The `#filtered` modifier returns a valid Hash with the subset of values that were valid, instead of failing the entire result if one or more values are invalid.
547
+
548
+ ```ruby
549
+ User = Types::Hash[name: String, age: Integer]
550
+ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
551
+ User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
552
+ ```
553
+
554
+
555
+
412
556
  ### Hash maps
413
557
 
414
558
  You can also use Hash syntax to define a hash map with specific types for all keys and values:
@@ -420,6 +564,37 @@ currencies.parse(usd: 'USD', gbp: 'GBP') # Ok
420
564
  currencies.parse('usd' => 'USD') # Error. Keys must be Symbols
421
565
  ```
422
566
 
567
+ Like other types, hash maps accept primitive types as keys and values:
568
+
569
+ ```ruby
570
+ currencies = Types::Hash[Symbol, String]
571
+ ```
572
+
573
+ And any `#===` interface as values, too:
574
+
575
+ ```ruby
576
+ names_and_emails = Types::Hash[String, /\w+@\w+/]
577
+
578
+ names_and_emails.parse('Joe' => 'joe@server.com', 'Rufus' => 'rufus')
579
+ ```
580
+
581
+ Use `Types::Value` to validate specific values (using `#==`)
582
+
583
+ ```ruby
584
+ names_and_ones = Types::Hash[String, Types::Integer.value(1)]
585
+ ```
586
+
587
+ #### `#filtered`
588
+
589
+ Calling the `#filtered` modifier on a Hash Map makes it return a sub set of the keys and values that are valid as per the key and value type definitions.
590
+
591
+ ```ruby
592
+ # Filter the ENV for all keys starting with S3_*
593
+ S3Config = Types::Hash[/^S3_\w+/, Types::Any].filtered
594
+
595
+ S3Config.parse(ENV.to_h) # { 'S3_BUCKET' => 'foo', 'S3_REGION' => 'us-east-1' }
596
+ ```
597
+
423
598
 
424
599
 
425
600
  ### `Types::Array`
@@ -429,19 +604,54 @@ names = Types::Array[Types::String.present]
429
604
  names_or_ages = Types::Array[Types::String.present | Types::Integer[21..]]
430
605
  ```
431
606
 
607
+ Arrays support primitive classes, or any `#===` interface:
608
+
609
+ ```ruby
610
+ strings = Types::Array[String]
611
+ emails = Types::Array[/@/]
612
+ # Similar to
613
+ emails = Types::Array[Types::String[/@/]]
614
+ ```
615
+
616
+ Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching agains the regular expression.
617
+
432
618
  #### Concurrent arrays
433
619
 
434
620
  Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
435
621
 
436
622
  ```ruby
437
- ImageDownload = Types::URL >> ->(result) { HTTP.get(result.value) }
623
+ ImageDownload = Types::URL >> ->(result) {
624
+ resp = HTTP.get(result.value)
625
+ if (200...300).include?(resp.status)
626
+ result.valid(resp.body)
627
+ else
628
+ result.invalid(error: resp.status)
629
+ end
630
+ }
438
631
  Images = Types::Array[ImageDownload].concurrent
439
632
 
440
633
  # Images are downloaded concurrently and returned in order.
441
634
  Images.parse(['https://images.com/1.png', 'https://images.com/2.png'])
442
635
  ```
443
636
 
444
- TODO: pluggable concurrently engines (Async?)
637
+ TODO: pluggable concurrency engines (Async?)
638
+
639
+ #### `#stream`
640
+
641
+ Turn an Array definition into an enumerator that yields each element wrapped in `Result::Valid` or `Result::Invalid`.
642
+
643
+ See `Types::Stream` below for more.
644
+
645
+ #### `#filtered`
646
+
647
+ The `#filtered` modifier makes an array definition return a subset of the input array where the values are valid, as per the array's element type.
648
+
649
+ ```ruby
650
+ j_names = Types::Array[Types::String[/^j/]].filtered
651
+ j_names.parse(%w[james ismael joe toby joan isabel]) # ["james", "joe", "joan"]
652
+ ```
653
+
654
+
445
655
 
446
656
  ### `Types::Tuple`
447
657
 
@@ -461,7 +671,69 @@ Error = Types::Tuple[:error, Types::String.present]
461
671
  Status = Ok | Error
462
672
  ```
463
673
 
674
+ ... Or any `#===` interface
675
+
676
+ ```ruby
677
+ NameAndEmail = Types::Tuple[String, /@/]
678
+ ```
679
+
680
+ As before, use `Types::Value` to check against literal values using `#==`
464
681
 
682
+ ```ruby
683
+ NameAndRegex = Types::Tuple[String, Types::Value[/@/]]
684
+ ```
685
+
686
+
687
+
688
+ ### `Types::Stream`
689
+
690
+ `Types::Stream` defines an enumerator that validates/coerces each element as it iterates.
691
+
692
+ This example streams a CSV file and validates rows as they are consumed.
693
+
694
+ ```ruby
695
+ require 'csv'
696
+
697
+ Row = Types::Tuple[Types::String.present, Types:Lax::Integer]
698
+ Stream = Types::Stream[Row]
699
+
700
+ data = CSV.new(File.new('./big-file.csv')).each # An Enumerator
701
+ # stream is an Enumerator that yields rows wrapped in[Result::Valid] or [Result::Invalid]
702
+ stream = Stream.parse(data)
703
+ stream.each.with_index(1) do |result, line|
704
+ if result.valid?
705
+ p result.value
706
+ else
707
+ p ["row at line #{line} is invalid: ", result.errors]
708
+ end
709
+ end
710
+ ```
711
+
712
+ #### `Types::Stream#filtered`
713
+
714
+ Use `#filtered` to turn a `Types::Stream` into a stream that only yields valid elements.
715
+
716
+ ```ruby
717
+ ValidElements = Types::Stream[Row].filtered
718
+ ValidElements.parse(data).each do |valid_row|
719
+ p valid_row
720
+ end
721
+ ```
722
+
723
+ #### `Types::Array#stream`
724
+
725
+ A `Types::Array` definition can be turned into a stream.
726
+
727
+ ```ruby
728
+ Arr = Types::Array[Integer]
729
+ Str = Arr.stream
730
+
731
+ Str.parse(data).each do |row|
732
+ row.valid?
733
+ row.errors
734
+ row.value
735
+ end
736
+ ```
465
737
 
466
738
  ### Plumb::Schema
467
739
 
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ Bundler.setup(:examples)
5
+ require 'plumb'
6
+ require 'json'
7
+ require 'fileutils'
8
+ require 'money'
9
+
10
+ Money.default_currency = Money::Currency.new('GBP')
11
+ Money.locale_backend = nil
12
+ Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN
13
+
14
+ # Different approaches to the Command Object pattern using composable Plumb types.
15
+ module Types
16
+ include Plumb::Types
17
+
18
+ # Note that within this `Types` module, when we say String, Integer etc, we mean Types::String, Types::Integer etc.
19
+ # Use ::String to refer to Ruby's String class.
20
+ #
21
+ ###############################################################
22
+ # Define core types in the domain
23
+ # The task is to process, validate and store mortgage applications.
24
+ ###############################################################
25
+
26
+ # Turn integers into Money objects (requires the money gem)
27
+ Amount = Integer.build(Money)
28
+
29
+ # A naive email check
30
+ Email = String[/\w+@\w+\.\w+/]
31
+
32
+ # A valid customer type
33
+ Customer = Hash[
34
+ name: String.present,
35
+ age?: Integer[18..],
36
+ email: Email
37
+ ]
38
+
39
+ # A step to validate a Mortgage application payload
40
+ # including valid customer, mortgage type and minimum property value.
41
+ MortgagePayload = Hash[
42
+ customer: Customer,
43
+ type: String.options(%w[first-time switcher remortgage]).default('first-time'),
44
+ property_value: Integer[100_000..] >> Amount,
45
+ mortgage_amount: Integer[50_000..] >> Amount,
46
+ term: Integer[5..30],
47
+ ]
48
+
49
+ # A domain validation step: the mortgage amount must be less than the property value.
50
+ # This is just a Proc that implements the `#call(Result::Valid) => Result::Valid | Result::Invalid` interface.
51
+ # # Note that this can be anything that supports that interface, like a lambda, a method, a class etc.
52
+ ValidateMortgageAmount = proc do |result|
53
+ if result.value[:mortgage_amount] > result.value[:property_value]
54
+ result.invalid(errors: { mortgage_amount: 'Cannot exceed property value' })
55
+ else
56
+ result
57
+ end
58
+ end
59
+
60
+ # A step to create a mortgage application
61
+ # This could be backed by a database (ex. ActiveRecord), a service (ex. HTTP API), etc.
62
+ # For this example I just save JSON files to disk.
63
+ class MortgageApplicationsStore
64
+ def self.call(result) = new.call(result)
65
+
66
+ def initialize(dir = './examples/data/applications')
67
+ @dir = dir
68
+ FileUtils.mkdir_p(dir)
69
+ end
70
+
71
+ # The Plumb::Step interface to make these objects composable.
72
+ # @param result [Plumb::Result::Valid]
73
+ # @return [Plumb::Result::Valid, Plumb::Result::Invalid]
74
+ def call(result)
75
+ if save(result.value)
76
+ result
77
+ else
78
+ result.invalid(errors: 'Could not save application')
79
+ end
80
+ end
81
+
82
+ def save(payload)
83
+ file_name = File.join(@dir, "#{Time.now.to_i}.json")
84
+ File.write(file_name, JSON.pretty_generate(payload))
85
+ end
86
+ end
87
+
88
+ # Finally, a step to send a notificiation to the customer.
89
+ # This should only run if the previous steps were successful.
90
+ NotifyCustomer = proc do |result|
91
+ # Send an email here.
92
+ puts "Sending notification to #{result.value[:customer][:email]}"
93
+ result
94
+ end
95
+
96
+ ###############################################################
97
+ # Option 1: define standalone steps and then pipe them together
98
+ ###############################################################
99
+ CreateMortgageApplication1 = MortgagePayload \
100
+ >> ValidateMortgageAmount \
101
+ >> MortgageApplicationsStore \
102
+ >> NotifyCustomer
103
+
104
+ ###############################################################
105
+ # Option 2: compose steps into a Plumb::Pipeline
106
+ # This is just a wrapper around step1 >> step2 >> step3 ...
107
+ # But the procedural style can make sequential steps easier to read and manage.
108
+ # Also to add/remove debugging and tracing steps.
109
+ ###############################################################
110
+ CreateMortgageApplication2 = Any.pipeline do |pl|
111
+ # The input payload
112
+ pl.step MortgagePayload
113
+
114
+ # Some inline logging to demostrate inline steps
115
+ # This is also useful for debugging and tracing.
116
+ pl.step do |result|
117
+ p [:after_payload, result.value]
118
+ result
119
+ end
120
+
121
+ # Domain validation
122
+ pl.step ValidateMortgageAmount
123
+
124
+ # Save the application
125
+ pl.step MortgageApplicationsStore
126
+
127
+ # Notifications
128
+ pl.step NotifyCustomer
129
+ end
130
+
131
+ # Note that I could have also started the pipeline directly off the MortgagePayload type.
132
+ # ex. CreateMortageApplication2 = MortgagePayload.pipeline do |pl
133
+ # For super-tiny command objects you can do it all inline:
134
+ #
135
+ # Types::Hash[
136
+ # name: String,
137
+ # age: Integer
138
+ # ].pipeline do |pl|
139
+ # pl.step do |result|
140
+ # .. some validations
141
+ # result
142
+ # end
143
+ # end
144
+ #
145
+ # Or you can use Method objects as steps
146
+ #
147
+ # pl.step SomeObject.method(:create)
148
+
149
+ ###############################################################
150
+ # Option 3: use your own class
151
+ # Use Plumb internally for validation and composition of shared steps or method objects.
152
+ ###############################################################
153
+ class CreateMortgageApplication3
154
+ def initialize
155
+ @pipeline = Types::Any.pipeline do |pl|
156
+ pl.step MortgagePayload
157
+ pl.step method(:validate)
158
+ pl.step method(:save)
159
+ pl.step method(:notify)
160
+ end
161
+ end
162
+
163
+ def run(payload)
164
+ @pipeline.resolve(payload)
165
+ end
166
+
167
+ private
168
+
169
+ def validate(result)
170
+ # etc
171
+ result
172
+ end
173
+
174
+ def save(result)
175
+ # etc
176
+ result
177
+ end
178
+
179
+ def notify(result)
180
+ # etc
181
+ result
182
+ end
183
+ end
184
+ end
185
+
186
+ # Uncomment each case to run
187
+ # p Types::CreateMortgageApplication1.resolve(
188
+ # customer: { name: 'John Doe', age: 30, email: 'john@doe.com' },
189
+ # property_value: 200_000,
190
+ # mortgage_amount: 150_000,
191
+ # term: 25
192
+ # )
193
+
194
+ # p Types::CreateMortgageApplication2.resolve(
195
+ # customer: { name: 'John Doe', age: 30, email: 'john@doe.com' },
196
+ # property_value: 200_000,
197
+ # mortgage_amount: 150_000,
198
+ # term: 25
199
+ # )
200
+
201
+ # Or, with invalid data
202
+ # p Types::CreateMortgageApplication2.resolve(
203
+ # customer: { name: 'John Doe', age: 30, email: 'john@doe.com' },
204
+ # property_value: 200_000,
205
+ # mortgage_amount: 201_000,
206
+ # term: 25
207
+ # )