plumb 0.0.2 → 0.0.3
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/README.md +287 -119
- data/examples/env_config.rb +122 -0
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/hash_class.rb +1 -3
- data/lib/plumb/json_schema_visitor.rb +49 -6
- data/lib/plumb/match_class.rb +3 -4
- data/lib/plumb/metadata_visitor.rb +10 -1
- data/lib/plumb/policies.rb +81 -0
- data/lib/plumb/policy.rb +31 -0
- data/lib/plumb/schema.rb +2 -2
- data/lib/plumb/steppable.rb +24 -39
- data/lib/plumb/types.rb +114 -23
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +6 -1
- data/lib/plumb.rb +52 -1
- metadata +8 -6
- data/lib/plumb/rules.rb +0 -102
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2f8b93e98170481b05c028f2ffa16c3f52cb57347efb373671cf4fbd915ca153
|
4
|
+
data.tar.gz: 89ae563b7e75ee76c2672f195ef94553123e41a6fffebc6835033f6401c7506a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: acaa62117892affe3eb921f76ade115f21f0dda711492a3ce9265f1ad859bf25b4dfecc1e9f920058d716b54d4ae1389c06b2d716ef9eac49d977eb4638d1db7
|
7
|
+
data.tar.gz: 200dc5ba480ddf4fedee46afa7c5b8e5c6191b8c796cb31df4b246841826aac9d8df181be2c3b5c90b53739fa791c3dc6c2b3d014a6822db74121f81873fa145
|
data/README.md
CHANGED
@@ -39,6 +39,52 @@ result.valid? # false
|
|
39
39
|
result.errors # ""
|
40
40
|
```
|
41
41
|
|
42
|
+
### Specialize your types with `#[]`
|
43
|
+
|
44
|
+
Use `#[]` to make your types match a class.
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
module Types
|
48
|
+
include Plumb::Types
|
49
|
+
|
50
|
+
String = Types::Any[::String]
|
51
|
+
Integer = Types::Any[::Integer]
|
52
|
+
end
|
53
|
+
|
54
|
+
Types::String.parse("hello") # => "hello"
|
55
|
+
Types::String.parse(10) # raises "Must be a String" (Plumb::TypeError)
|
56
|
+
```
|
57
|
+
|
58
|
+
Plumb ships with basic types already defined, such as `Types::String` and `Types::Integer`. See the full list below.
|
59
|
+
|
60
|
+
The `#[]` method is not just for classes. It works with anything that responds to `#===`
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
# Match against a regex
|
64
|
+
Email = Types::String[/@/] # ie Types::Any[String][/@/]
|
65
|
+
|
66
|
+
Email.parse('hello') # fails
|
67
|
+
Email.parse('hello@server.com') # 'hello@server.com'
|
68
|
+
|
69
|
+
# Or a Range
|
70
|
+
AdultAge = Types::Integer[18..]
|
71
|
+
AdultAge.parse(20) # 20
|
72
|
+
AdultAge.parse(17) # raises "Must be within 18.."" (Plumb::TypeError)
|
73
|
+
|
74
|
+
# Or literal values
|
75
|
+
Twenty = Types::Integer[20]
|
76
|
+
Twenty.parse(20) # 20
|
77
|
+
Twenty.parse(21) # type error
|
78
|
+
```
|
79
|
+
|
80
|
+
It can be combined with other methods. For example to cast strings as integers, but only if they _look_ like integers.
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
StringToInt = Types::String[/^\d+$/].transform(::Integer, &:to_i)
|
84
|
+
|
85
|
+
StringToInt.parse('100') # => 100
|
86
|
+
StringToInt.parse('100lol') # fails
|
87
|
+
```
|
42
88
|
|
43
89
|
### `#resolve(value) => Result`
|
44
90
|
|
@@ -55,8 +101,6 @@ result.value # '10'
|
|
55
101
|
result.errors # 'must be an Integer'
|
56
102
|
```
|
57
103
|
|
58
|
-
|
59
|
-
|
60
104
|
### `#parse(value) => value`
|
61
105
|
|
62
106
|
`#parse` takes an input value and returns the parsed/coerced value if successful. or it raises an exception if failed.
|
@@ -68,6 +112,80 @@ Types::Integer.parse('10') # raises Plumb::TypeError
|
|
68
112
|
|
69
113
|
|
70
114
|
|
115
|
+
### Composite types
|
116
|
+
|
117
|
+
Some built-in types such as `Types::Array` and `Types::Hash` allow defininig array or hash data structures composed of other types.
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
# A user hash
|
121
|
+
User = Types::Hash[name: Types::String, email: Email, age: AdultAge]
|
122
|
+
|
123
|
+
# An array of User hashes
|
124
|
+
Users = Types::Array[User]
|
125
|
+
|
126
|
+
joe = User.parse({ name: 'Joe', email: 'joe@email.com', age: 20}) # returns valid hash
|
127
|
+
Users.parse([joe]) # returns valid array of user hashes
|
128
|
+
```
|
129
|
+
|
130
|
+
More about [Types::Array](#typeshash) and [Types::Array](#typesarray). There's also tuples and hash maps, and it's possible to create your own composite types.
|
131
|
+
|
132
|
+
## Type composition
|
133
|
+
|
134
|
+
At the core, Plumb types are little [Railway-oriented pipelines](https://ismaelcelis.com/posts/composable-pipelines-in-ruby/) that can be composed together with _and_, _or_ and _not_ semantics. Everything else builds on top of these two ideas.
|
135
|
+
|
136
|
+
### Composing types with `#>>` ("And")
|
137
|
+
|
138
|
+
```ruby
|
139
|
+
Email = Types::String[/@/]
|
140
|
+
# You can compose procs and lambdas, or other types.
|
141
|
+
Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") }
|
142
|
+
|
143
|
+
Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
144
|
+
```
|
145
|
+
|
146
|
+
### Disjunction with `#|` ("Or")
|
147
|
+
|
148
|
+
```ruby
|
149
|
+
StringOrInt = Types::String | Types::Integer
|
150
|
+
StringOrInt.parse('hello') # "hello"
|
151
|
+
StringOrInt.parse(10) # 10
|
152
|
+
StringOrInt.parse({}) # raises Plumb::TypeError
|
153
|
+
```
|
154
|
+
|
155
|
+
Custom default value logic for non-emails
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
EmailOrDefault = Greeting | Types::Static['no email']
|
159
|
+
EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
160
|
+
EmailOrDefault.parse('nope') # "no email"
|
161
|
+
```
|
162
|
+
|
163
|
+
## Composing with `#>>` and `#|`
|
164
|
+
|
165
|
+
This more elaborate example defines a combination of types which, when composed together with `>>` and `|`, can coerce strings or integers into Money instances with currency.
|
166
|
+
|
167
|
+
```ruby
|
168
|
+
require 'money'
|
169
|
+
|
170
|
+
module Types
|
171
|
+
include Plumb::Types
|
172
|
+
|
173
|
+
Money = Any[::Money]
|
174
|
+
IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') }
|
175
|
+
StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i)
|
176
|
+
USD = Money.check { |amount| amount.currency.code == 'UDS' }
|
177
|
+
ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') }
|
178
|
+
|
179
|
+
FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD)
|
180
|
+
end
|
181
|
+
|
182
|
+
FlexibleUSD.parse('1000') # Money(USD 10.00)
|
183
|
+
FlexibleUSD.parse(1000) # Money(USD 10.00)
|
184
|
+
FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
|
185
|
+
```
|
186
|
+
|
187
|
+
You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/examples)
|
188
|
+
|
71
189
|
## Built-in types
|
72
190
|
|
73
191
|
* `Types::Value`
|
@@ -79,12 +197,10 @@ Types::Integer.parse('10') # raises Plumb::TypeError
|
|
79
197
|
* `Types::False`
|
80
198
|
* `Types::Tuple`
|
81
199
|
* `Types::Split`
|
82
|
-
* `Types::Blank`
|
83
200
|
* `Types::Any`
|
84
201
|
* `Types::Static`
|
85
202
|
* `Types::Undefined`
|
86
203
|
* `Types::Nil`
|
87
|
-
* `Types::Present`
|
88
204
|
* `Types::Integer`
|
89
205
|
* `Types::Numeric`
|
90
206
|
* `Types::String`
|
@@ -99,6 +215,10 @@ Types::Integer.parse('10') # raises Plumb::TypeError
|
|
99
215
|
|
100
216
|
|
101
217
|
|
218
|
+
### Policies
|
219
|
+
|
220
|
+
Policies are methods that encapsulate common compositions. Plumb ships with some, listed below, and you can also define your own.
|
221
|
+
|
102
222
|
### `#present`
|
103
223
|
|
104
224
|
Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`)
|
@@ -119,14 +239,12 @@ nullable_str.parse('hello') # 'hello'
|
|
119
239
|
nullable_str.parse(10) # TypeError
|
120
240
|
```
|
121
241
|
|
122
|
-
Note that this
|
242
|
+
Note that this just encapsulates the following composition:
|
123
243
|
|
124
244
|
```ruby
|
125
245
|
nullable_str = Types::String | Types::Nil
|
126
246
|
```
|
127
247
|
|
128
|
-
|
129
|
-
|
130
248
|
### `#not`
|
131
249
|
|
132
250
|
Negates a type.
|
@@ -155,8 +273,6 @@ type.resolve(['a', 'a', 'b']) # Valid
|
|
155
273
|
type.resolve(['a', 'x', 'b']) # Failure
|
156
274
|
```
|
157
275
|
|
158
|
-
|
159
|
-
|
160
276
|
### `#transform`
|
161
277
|
|
162
278
|
Transform value. Requires specifying the resulting type of the value after transformation.
|
@@ -240,45 +356,6 @@ Same if you want to apply a default to several cases.
|
|
240
356
|
str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
|
241
357
|
```
|
242
358
|
|
243
|
-
|
244
|
-
|
245
|
-
### `#match` and `#[]`
|
246
|
-
|
247
|
-
Checks the value against a regular expression (or anything that responds to `#===`).
|
248
|
-
|
249
|
-
```ruby
|
250
|
-
email = Types::String.match(/@/)
|
251
|
-
# Same as
|
252
|
-
email = Types::String[/@/]
|
253
|
-
email.parse('hello') # fails
|
254
|
-
email.parse('hello@server.com') # 'hello@server.com'
|
255
|
-
```
|
256
|
-
|
257
|
-
It can be combined with other methods. For example to cast strings as integers, but only if they _look_ like integers.
|
258
|
-
|
259
|
-
```ruby
|
260
|
-
StringToInt = Types::String[/^\d+$/].transform(::Integer, &:to_i)
|
261
|
-
|
262
|
-
StringToInt.parse('100') # => 100
|
263
|
-
StringToInt.parse('100lol') # fails
|
264
|
-
```
|
265
|
-
|
266
|
-
It can be used with other `#===` interfaces.
|
267
|
-
|
268
|
-
```ruby
|
269
|
-
AgeBracket = Types::Integer[21..45]
|
270
|
-
|
271
|
-
AgeBracket.parse(22) # 22
|
272
|
-
AgeBracket.parse(20) # fails
|
273
|
-
|
274
|
-
# With literal values
|
275
|
-
Twenty = Types::Integer[20]
|
276
|
-
Twenty.parse(20) # 20
|
277
|
-
Twenty.parse(21) # type error
|
278
|
-
```
|
279
|
-
|
280
|
-
|
281
|
-
|
282
359
|
### `#build`
|
283
360
|
|
284
361
|
Build a custom object or class.
|
@@ -312,8 +389,6 @@ Note that this case is identical to `#transform` with a block.
|
|
312
389
|
StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
|
313
390
|
```
|
314
391
|
|
315
|
-
|
316
|
-
|
317
392
|
### `#check`
|
318
393
|
|
319
394
|
Pass the value through an arbitrary validation
|
@@ -343,8 +418,6 @@ All scalar types support this:
|
|
343
418
|
ten = Types::Integer.value(10)
|
344
419
|
```
|
345
420
|
|
346
|
-
|
347
|
-
|
348
421
|
### `#meta` and `#metadata`
|
349
422
|
|
350
423
|
Add metadata to a type
|
@@ -373,7 +446,60 @@ Types::String.transform(Integer, &:to_i).metadata[:type] # Integer
|
|
373
446
|
|
374
447
|
TODO: document custom visitors.
|
375
448
|
|
376
|
-
|
449
|
+
### Other policies
|
450
|
+
|
451
|
+
There's some other built-in "policies" that can be used via the `#policy` method. Helpers such as `#default` and `#present` are shortcuts for this and can also be used via `#policy(default: 'Hello')` or `#policy(:present)` See [custom policies](#custom-policies) for how to define your own policies.
|
452
|
+
|
453
|
+
#### `:respond_to`
|
454
|
+
|
455
|
+
Similar to `Types::Interface`, this is a quick way to assert that a value supports one or more methods.
|
456
|
+
|
457
|
+
```ruby
|
458
|
+
List = Types::Any.policy(respond_to: :each)
|
459
|
+
# or
|
460
|
+
List = Types::Any.policy(respond_to: [:each, :[], :size)
|
461
|
+
```
|
462
|
+
|
463
|
+
#### `:excluded_from`
|
464
|
+
|
465
|
+
The opposite of `#options`, this policy validates that the value _is not_ included in a list.
|
466
|
+
|
467
|
+
```ruby
|
468
|
+
Name = Types::String.policy(excluded_from: ['Joe', 'Joan'])
|
469
|
+
```
|
470
|
+
|
471
|
+
#### `:size`
|
472
|
+
|
473
|
+
Works for any value that responds to `#size` and validates that the value's size matches the argument.
|
474
|
+
|
475
|
+
```ruby
|
476
|
+
LimitedArray = Types::Array[String].policy(size: 10)
|
477
|
+
LimitedString = Types::String.policy(size: 10)
|
478
|
+
LimitedSet = Types::Any[Set].policy(size: 10)
|
479
|
+
```
|
480
|
+
|
481
|
+
The size is matched via `#===`, so ranges also work.
|
482
|
+
|
483
|
+
```ruby
|
484
|
+
Password = Types::String.policy(size: 10..20)
|
485
|
+
```
|
486
|
+
|
487
|
+
#### `:split` (strings only)
|
488
|
+
|
489
|
+
Splits string values by a separator (default: `,`).
|
490
|
+
|
491
|
+
```ruby
|
492
|
+
CSVLine = Types::String.split
|
493
|
+
CSVLine.parse('a,b,c') # => ['a', 'b', 'c']
|
494
|
+
|
495
|
+
# Or, with custom separator
|
496
|
+
CSVLine = Types::String.split(/\s*;\s*/)
|
497
|
+
CSVLine.parse('a;b;c') # => ['a', 'b', 'c']
|
498
|
+
```
|
499
|
+
|
500
|
+
|
501
|
+
|
502
|
+
### `Types::Hash`
|
377
503
|
|
378
504
|
```ruby
|
379
505
|
Employee = Types::Hash[
|
@@ -466,8 +592,6 @@ Types::Hash[
|
|
466
592
|
]
|
467
593
|
```
|
468
594
|
|
469
|
-
|
470
|
-
|
471
595
|
#### Merging hash definitions
|
472
596
|
|
473
597
|
Use `Types::Hash#+` to merge two definitions. Keys in the second hash override the first one's.
|
@@ -478,8 +602,6 @@ Employee = Types::Hash[name: Types::String, company: Types::String]
|
|
478
602
|
StaffMember = User + Employee # Hash[:name, :age, :company]
|
479
603
|
```
|
480
604
|
|
481
|
-
|
482
|
-
|
483
605
|
#### Hash intersections
|
484
606
|
|
485
607
|
Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
|
@@ -488,8 +610,6 @@ Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
|
|
488
610
|
intersection = User & Employee # Hash[:name]
|
489
611
|
```
|
490
612
|
|
491
|
-
|
492
|
-
|
493
613
|
#### `Types::Hash#tagged_by`
|
494
614
|
|
495
615
|
Use `#tagged_by` to resolve what definition to use based on the value of a common key.
|
@@ -509,8 +629,6 @@ Events = Types::Hash.tagged_by(
|
|
509
629
|
Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
|
510
630
|
```
|
511
631
|
|
512
|
-
|
513
|
-
|
514
632
|
#### `Types::Hash#inclusive`
|
515
633
|
|
516
634
|
Use `#inclusive` to preserve input keys not defined in the hash schema.
|
@@ -551,8 +669,6 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
|
|
551
669
|
User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
|
552
670
|
```
|
553
671
|
|
554
|
-
|
555
|
-
|
556
672
|
### Hash maps
|
557
673
|
|
558
674
|
You can also use Hash syntax to define a hash map with specific types for all keys and values:
|
@@ -747,57 +863,6 @@ TODO
|
|
747
863
|
|
748
864
|
TODO
|
749
865
|
|
750
|
-
## Composing types with `#>>` ("And")
|
751
|
-
|
752
|
-
```ruby
|
753
|
-
Email = Types::String.match(/@/)
|
754
|
-
Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") }
|
755
|
-
|
756
|
-
Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
757
|
-
```
|
758
|
-
|
759
|
-
|
760
|
-
## Disjunction with `#|` ("Or")
|
761
|
-
|
762
|
-
```ruby
|
763
|
-
StringOrInt = Types::String | Types::Integer
|
764
|
-
StringOrInt.parse('hello') # "hello"
|
765
|
-
StringOrInt.parse(10) # 10
|
766
|
-
StringOrInt.parse({}) # raises Plumb::TypeError
|
767
|
-
```
|
768
|
-
|
769
|
-
Custom default value logic for non-emails
|
770
|
-
|
771
|
-
```ruby
|
772
|
-
EmailOrDefault = Greeting | Types::Static['no email']
|
773
|
-
EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
774
|
-
EmailOrDefault.parse('nope') # "no email"
|
775
|
-
```
|
776
|
-
|
777
|
-
## Composing with `#>>` and `#|`
|
778
|
-
|
779
|
-
```ruby
|
780
|
-
require 'money'
|
781
|
-
|
782
|
-
module Types
|
783
|
-
include Plumb::Types
|
784
|
-
|
785
|
-
Money = Any[::Money]
|
786
|
-
IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') }
|
787
|
-
StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i)
|
788
|
-
USD = Money.check { |amount| amount.currency.code == 'UDS' }
|
789
|
-
ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') }
|
790
|
-
|
791
|
-
FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD)
|
792
|
-
end
|
793
|
-
|
794
|
-
FlexibleUSD.parse('1000') # Money(USD 10.00)
|
795
|
-
FlexibleUSD.parse(1000) # Money(USD 10.00)
|
796
|
-
FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
|
797
|
-
```
|
798
|
-
|
799
|
-
|
800
|
-
|
801
866
|
### Recursive types
|
802
867
|
|
803
868
|
You can use a proc to defer evaluation of recursive definitions.
|
@@ -831,10 +896,6 @@ LinkedList = Types::Hash[
|
|
831
896
|
|
832
897
|
|
833
898
|
|
834
|
-
### Type-specific Rules
|
835
|
-
|
836
|
-
TODO
|
837
|
-
|
838
899
|
### Custom types
|
839
900
|
|
840
901
|
Compose procs or lambdas directly
|
@@ -859,8 +920,99 @@ end
|
|
859
920
|
MyType = Types::String >> Greeting.new('Hola')
|
860
921
|
```
|
861
922
|
|
862
|
-
|
923
|
+
### Custom policies
|
924
|
+
|
925
|
+
`Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
|
926
|
+
|
927
|
+
This example defines a `:default_if_nil` policy that returns a default if the value is `nil`.
|
928
|
+
|
929
|
+
```ruby
|
930
|
+
Plumb.policy :default_if_nil do |type, default_value|
|
931
|
+
type | (Types::Nil >> Types::Static[default_value])
|
932
|
+
end
|
933
|
+
```
|
934
|
+
|
935
|
+
It can be used for any of your own types.
|
936
|
+
|
937
|
+
```ruby
|
938
|
+
StringWithDefault = Types::String.policy(default_if_nil: 'nothing here')
|
939
|
+
StringWithDefault.parse('hello') # 'hello'
|
940
|
+
StringWithDefault.parse(nil) # 'nothing here'
|
941
|
+
```
|
942
|
+
|
943
|
+
The `#policy` helper supports applying multiply policies.
|
944
|
+
|
945
|
+
```ruby
|
946
|
+
Types::String.policy(default_if_nil: 'nothing here', size: (10..20))
|
947
|
+
```
|
948
|
+
|
949
|
+
|
950
|
+
|
951
|
+
#### Policies as helper methods
|
952
|
+
|
953
|
+
Use the `helper: true` option to register the policy as a method you can call on types directly.
|
954
|
+
|
955
|
+
```ruby
|
956
|
+
Plumb.policy :default_if_nil, helper: true do |type, default_value|
|
957
|
+
type | (Types::Nil >> Types::Static[default_value])
|
958
|
+
end
|
959
|
+
|
960
|
+
# Now use #default_if_nil directly
|
961
|
+
StringWithDefault = Types::String.default_if_nil('nothing here')
|
962
|
+
```
|
963
|
+
|
964
|
+
Many built-in helpers such as `#default` and `#options` are implemented as policies. This means that you can overwrite their default behaviour by defining a policy with the same name (use with caution!).
|
965
|
+
|
966
|
+
#### Type-specific policies
|
967
|
+
|
968
|
+
You can use the `for_type:` option to define policies that only apply to steps that output certain types. This example only applies for types that return `Integer` values.
|
969
|
+
|
970
|
+
```ruby
|
971
|
+
Plumb.policy :multiply_by, for_type: Integer, helper: true do |type, factor|
|
972
|
+
type.invoke(:*, factor)
|
973
|
+
end
|
974
|
+
|
975
|
+
Doubled = Types::Integer.multiply_by(2)
|
976
|
+
Doubled.parse(2) # 4
|
977
|
+
|
978
|
+
# Tryin to apply this policy to a non Integer will raise an exception
|
979
|
+
DoubledString = Types::String.multiply_by(2) # raises error
|
980
|
+
```
|
981
|
+
|
982
|
+
#### Interface-specific policies
|
983
|
+
|
984
|
+
`for_type`also supports a Symbol for a method name, so that the policy can be applied to any types that support that method.
|
985
|
+
|
986
|
+
This example allows the `multiply_by` policy to work with any type that can be multiplied (by supporting the `:*` method).
|
987
|
+
|
988
|
+
```ruby
|
989
|
+
Plumb.policy :multiply_by, for_type: :*, helper: true do |type, factor|
|
990
|
+
type.invoke(:*, factor)
|
991
|
+
end
|
992
|
+
|
993
|
+
# Now it works with anything that can be multiplied.
|
994
|
+
DoubledNumeric = Types::Numeric.multiply_by(2)
|
995
|
+
DoubledMoney = Types::Any[Money].multiply_by(2)
|
996
|
+
```
|
997
|
+
|
998
|
+
#### Self-contained policy modules
|
999
|
+
|
1000
|
+
You can register a module, class or module with a three-method interface as a policy. This is so that policies can have their own namespace if they need local constants or private methods. For example, this is how the `:split` policy for strings is defined.
|
863
1001
|
|
1002
|
+
```ruby
|
1003
|
+
module SplitPolicy
|
1004
|
+
DEFAULT_SEPARATOR = /\s*,\s*/
|
1005
|
+
|
1006
|
+
def self.call(type, separator = DEFAULT_SEPARATOR)
|
1007
|
+
type.transform(Array) { |v| v.split(separator) }
|
1008
|
+
end
|
1009
|
+
|
1010
|
+
def self.for_type = ::String
|
1011
|
+
def self.helper = false
|
1012
|
+
end
|
1013
|
+
|
1014
|
+
Plumb.policy :split, SplitPolicy
|
1015
|
+
```
|
864
1016
|
|
865
1017
|
### JSON Schema
|
866
1018
|
|
@@ -883,6 +1035,22 @@ json_schema = Plumb::JSONSchemaVisitor.call(User)
|
|
883
1035
|
}
|
884
1036
|
```
|
885
1037
|
|
1038
|
+
The built-in JSON Schema generator handles most standard types and compositions. You can add or override handles on a per-type basis with:
|
1039
|
+
|
1040
|
+
```ruby
|
1041
|
+
Plumb::JSONSchemaVisitor.on(:not) do |node, props|
|
1042
|
+
props.merge('not' => visit(node.step))
|
1043
|
+
end
|
1044
|
+
|
1045
|
+
# Example
|
1046
|
+
type = Types::Decimal.not
|
1047
|
+
schema = Plumb::JSONSchemaVisitor.visit(type) # { 'not' => { 'type' => 'number' } }
|
1048
|
+
```
|
1049
|
+
|
1050
|
+
#### JSON Schema handlers for custom policies
|
1051
|
+
|
1052
|
+
TODO. See `Plumb::JSONSchemaVisitor`.
|
1053
|
+
|
886
1054
|
|
887
1055
|
|
888
1056
|
## Development
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'plumb'
|
5
|
+
require 'debug'
|
6
|
+
|
7
|
+
# Types and pipelines for defining and parsing ENV configuration
|
8
|
+
# Run with `bundle exec ruby examples/env_config.rb`
|
9
|
+
#
|
10
|
+
# Given an ENV with variables to configure one of three types of network/IO clients,
|
11
|
+
# parse, validate and coerce the configuration into the appropriate client object.
|
12
|
+
# ENV vars are expected to be prefixed with `FILE_UPLOAD_`, followed by the client type.
|
13
|
+
# See example usage at the bottom of this file.
|
14
|
+
module Types
|
15
|
+
include Plumb::Types
|
16
|
+
|
17
|
+
# Define a custom policy to extract a string using a regular expression.
|
18
|
+
# Policies are factories for custom type compositions.
|
19
|
+
#
|
20
|
+
# Usage:
|
21
|
+
# type = Types::String.extract(/^FOO_(\w+)$/).invoke(:[], 1)
|
22
|
+
# type.parse('FOO_BAR') # => 'BAR'
|
23
|
+
#
|
24
|
+
Plumb.policy :extract, for_type: ::String, helper: true do |type, regex|
|
25
|
+
type >> lambda do |result|
|
26
|
+
match = result.value.match(regex)
|
27
|
+
return result.invalid(errors: "does not match #{regex.source}") if match.nil?
|
28
|
+
return result.invalid(errors: 'no captures') if match.captures.none?
|
29
|
+
|
30
|
+
result.valid(match)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# A dummy S3 client
|
35
|
+
S3Client = Data.define(:bucket, :region)
|
36
|
+
|
37
|
+
# A dummy SFTP client
|
38
|
+
SFTPClient = Data.define(:host, :username, :password)
|
39
|
+
|
40
|
+
# Map these fields to an S3 client
|
41
|
+
S3Config = Types::Hash[
|
42
|
+
transport: 's3',
|
43
|
+
bucket: String.present,
|
44
|
+
region: String.options(%w[us-west-1 us-west-2 us-east-1])
|
45
|
+
].invoke(:except, :transport).build(S3Client) { |h| S3Client.new(**h) }
|
46
|
+
|
47
|
+
# Map these fields to an SFTP client
|
48
|
+
SFTPConfig = Types::Hash[
|
49
|
+
transport: 'sftp',
|
50
|
+
host: String.present,
|
51
|
+
username: String.present,
|
52
|
+
password: String.present,
|
53
|
+
].invoke(:except, :transport).build(SFTPClient) { |h| SFTPClient.new(**h) }
|
54
|
+
|
55
|
+
# Map these fields to a File client
|
56
|
+
FileConfig = Types::Hash[
|
57
|
+
transport: 'file',
|
58
|
+
path: String.present,
|
59
|
+
].invoke(:[], :path).build(::File)
|
60
|
+
|
61
|
+
# Take a string such as 'FILE_UPLOAD_BUCKET', extract the `BUCKET` bit,
|
62
|
+
# downcase and symbolize it.
|
63
|
+
FileUploadKey = String.extract(/^FILE_UPLOAD_(\w+)$/).invoke(:[], 1).invoke(%i[downcase to_sym])
|
64
|
+
|
65
|
+
# Filter a Hash (or ENV) to keys that match the FILE_UPLOAD_* pattern
|
66
|
+
FileUploadHash = Types::Hash[FileUploadKey, Any].filtered
|
67
|
+
|
68
|
+
# Pipeline syntax to put the program together
|
69
|
+
FileUploadClientFromENV = Any.pipeline do |pl|
|
70
|
+
# 1. Accept any Hash-like object (e.g. ENV)
|
71
|
+
pl.step Types::Interface[:[], :key?, :each, :to_h]
|
72
|
+
|
73
|
+
# 2. Transform it to a Hash
|
74
|
+
pl.step Any.transform(::Hash, &:to_h)
|
75
|
+
|
76
|
+
# 3. Filter keys with FILE_UPLOAD_* prefix
|
77
|
+
pl.step FileUploadHash
|
78
|
+
|
79
|
+
# 4. Parse the configuration for a particular client object
|
80
|
+
pl.step(S3Config | SFTPConfig | FileConfig)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Ex.
|
84
|
+
# client = FileUploadClientFromENV.parse(ENV) # SFTP, S3 or File client
|
85
|
+
|
86
|
+
# The above is the same as:
|
87
|
+
#
|
88
|
+
# FileUploadClientFromENV = Types::Interface[:[], :key?, :each, :to_h] \
|
89
|
+
# .transform(::Hash, &:to_h) >> \
|
90
|
+
# Types::Hash[FileUploadKey, Any].filtered >> \
|
91
|
+
# (S3Config | SFTPConfig | FileConfig)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Simulated ENV hashes. Just use ::ENV for the real thing.
|
95
|
+
ENV_S3 = {
|
96
|
+
'FILE_UPLOAD_TRANSPORT' => 's3',
|
97
|
+
'FILE_UPLOAD_BUCKET' => 'my-bucket',
|
98
|
+
'FILE_UPLOAD_REGION' => 'us-west-2',
|
99
|
+
'SOMETHING_ELSE' => 'ignored'
|
100
|
+
}.freeze
|
101
|
+
# => S3Client.new(bucket: 'my-bucket', region: 'us-west-2')
|
102
|
+
|
103
|
+
ENV_SFTP = {
|
104
|
+
'FILE_UPLOAD_TRANSPORT' => 'sftp',
|
105
|
+
'FILE_UPLOAD_HOST' => 'sftp.example.com',
|
106
|
+
'FILE_UPLOAD_USERNAME' => 'username',
|
107
|
+
'FILE_UPLOAD_PASSWORD' => 'password',
|
108
|
+
'SOMETHING_ELSE' => 'ignored'
|
109
|
+
}.freeze
|
110
|
+
# => SFTPClient.new(host: 'sftp.example.com', username: 'username', password: 'password')
|
111
|
+
|
112
|
+
ENV_FILE = {
|
113
|
+
'FILE_UPLOAD_TRANSPORT' => 'file',
|
114
|
+
'FILE_UPLOAD_PATH' => File.join('examples', 'programmers.csv')
|
115
|
+
}.freeze
|
116
|
+
|
117
|
+
p Types::FileUploadClientFromENV.parse(ENV_S3) # #<data Types::S3Client bucket="my-bucket", region="us-west-2">
|
118
|
+
p Types::FileUploadClientFromENV.parse(ENV_SFTP) # #<data Types::SFTPClient host="sftp.example.com", username="username", password="password">
|
119
|
+
p Types::FileUploadClientFromENV.parse(ENV_FILE) # #<File path="examples/programmers.csv">
|
120
|
+
|
121
|
+
# Or with invalid or missing configuration
|
122
|
+
# p Types::FileUploadClientFromENV.parse({}) # raises error
|
data/examples/weekdays.rb
CHANGED
@@ -41,7 +41,7 @@ module Types
|
|
41
41
|
# Ex. [1, 2, 3, 4, 5, 6, 7], [1, 2, 4], ['monday', 'tuesday', 'wednesday', 7]
|
42
42
|
# Turn day names into numbers, and sort the array.
|
43
43
|
Week = Array[DayNameOrNumber]
|
44
|
-
.
|
44
|
+
.policy(size: 1..7)
|
45
45
|
.check('repeated days') { |days| days.uniq.size == days.size }
|
46
46
|
.transform(::Array, &:sort)
|
47
47
|
end
|
data/lib/plumb/hash_class.rb
CHANGED
@@ -30,9 +30,7 @@ module Plumb
|
|
30
30
|
case args
|
31
31
|
in [::Hash => hash]
|
32
32
|
self.class.new(schema: _schema.merge(wrap_keys_and_values(hash)), inclusive: @inclusive)
|
33
|
-
in [
|
34
|
-
HashMap.new(key_type, Steppable.wrap(value_type))
|
35
|
-
in [Class => key_type, value_type]
|
33
|
+
in [key_type, value_type]
|
36
34
|
HashMap.new(Steppable.wrap(key_type), Steppable.wrap(value_type))
|
37
35
|
else
|
38
36
|
raise ::ArgumentError, "unexpected value to Types::Hash#schema #{args.inspect}"
|