plumb 0.0.2 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- 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}"
|