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 +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
|
+
# )
|