plumb 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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
+ # )