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 +4 -4
- data/.rubocop.yml +2 -0
- data/README.md +291 -19
- data/examples/command_objects.rb +207 -0
- data/examples/concurrent_downloads.rb +107 -0
- data/examples/csv_stream.rb +97 -0
- data/examples/programmers.csv +201 -0
- data/examples/weekdays.rb +66 -0
- data/lib/plumb/array_class.rb +25 -19
- data/lib/plumb/build.rb +3 -0
- data/lib/plumb/hash_class.rb +44 -13
- data/lib/plumb/hash_map.rb +34 -0
- data/lib/plumb/interface_class.rb +6 -4
- data/lib/plumb/json_schema_visitor.rb +117 -74
- data/lib/plumb/match_class.rb +8 -5
- data/lib/plumb/metadata.rb +3 -0
- data/lib/plumb/metadata_visitor.rb +45 -40
- data/lib/plumb/rules.rb +6 -7
- data/lib/plumb/schema.rb +37 -41
- data/lib/plumb/static_class.rb +4 -4
- data/lib/plumb/step.rb +6 -1
- data/lib/plumb/steppable.rb +36 -34
- data/lib/plumb/stream_class.rb +61 -0
- data/lib/plumb/tagged_hash.rb +12 -3
- data/lib/plumb/transform.rb +6 -1
- data/lib/plumb/tuple_class.rb +8 -5
- data/lib/plumb/types.rb +19 -60
- data/lib/plumb/value_class.rb +5 -2
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +13 -9
- data/lib/plumb.rb +1 -0
- metadata +8 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: db1a6e5f70bf36e91d053ff465e9f566cd4371e4620dac88aea6f028918311a8
|
4
|
+
data.tar.gz: 13e986c5a7815c3ecbdf6f1f6cabb4d9341b88421d8048a7e7182d9b638e1632
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 540cb16d4ab114931dad7b278578428357341e5318f5371a40d22b6d53714fe71290cb66892725316faf2060e5a1ca8e4c318951e9dc8eeb7839eac2a38d4800
|
7
|
+
data.tar.gz: 6404ab512cb061af57be9d7b3e41dfb967e3e651819f4fb540a6e018f55a5ab18a976649fe891338181f7e769b9efb0a29df1e8dd0586471b14ea0b7b04a511d
|
data/.rubocop.yml
CHANGED
data/README.md
CHANGED
@@ -1,6 +1,14 @@
|
|
1
1
|
# Plumb
|
2
2
|
|
3
|
-
|
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
|
-
|
255
|
-
|
256
|
-
new(attrs)
|
257
|
-
end
|
258
|
-
end
|
296
|
+
# https://github.com/RubyMoney/monetize
|
297
|
+
require 'monetize'
|
259
298
|
|
260
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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) {
|
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
|
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
|
+
# )
|