plumb 0.0.1 → 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/.rubocop.yml +2 -0
- data/README.md +558 -118
- data/examples/command_objects.rb +207 -0
- data/examples/concurrent_downloads.rb +107 -0
- data/examples/csv_stream.rb +97 -0
- data/examples/env_config.rb +122 -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 +42 -13
- data/lib/plumb/hash_map.rb +34 -0
- data/lib/plumb/interface_class.rb +6 -4
- data/lib/plumb/json_schema_visitor.rb +157 -71
- data/lib/plumb/match_class.rb +8 -6
- data/lib/plumb/metadata.rb +3 -0
- data/lib/plumb/metadata_visitor.rb +54 -40
- data/lib/plumb/policies.rb +81 -0
- data/lib/plumb/policy.rb +31 -0
- data/lib/plumb/schema.rb +39 -43
- data/lib/plumb/static_class.rb +4 -4
- data/lib/plumb/step.rb +6 -1
- data/lib/plumb/steppable.rb +47 -60
- 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 +119 -69
- data/lib/plumb/value_class.rb +5 -2
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +19 -10
- data/lib/plumb.rb +53 -1
- metadata +14 -6
- data/lib/plumb/rules.rb +0 -103
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
|
@@ -31,7 +39,52 @@ result.valid? # false
|
|
31
39
|
result.errors # ""
|
32
40
|
```
|
33
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)
|
34
84
|
|
85
|
+
StringToInt.parse('100') # => 100
|
86
|
+
StringToInt.parse('100lol') # fails
|
87
|
+
```
|
35
88
|
|
36
89
|
### `#resolve(value) => Result`
|
37
90
|
|
@@ -48,8 +101,6 @@ result.value # '10'
|
|
48
101
|
result.errors # 'must be an Integer'
|
49
102
|
```
|
50
103
|
|
51
|
-
|
52
|
-
|
53
104
|
### `#parse(value) => value`
|
54
105
|
|
55
106
|
`#parse` takes an input value and returns the parsed/coerced value if successful. or it raises an exception if failed.
|
@@ -61,6 +112,80 @@ Types::Integer.parse('10') # raises Plumb::TypeError
|
|
61
112
|
|
62
113
|
|
63
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
|
+
|
64
189
|
## Built-in types
|
65
190
|
|
66
191
|
* `Types::Value`
|
@@ -72,12 +197,10 @@ Types::Integer.parse('10') # raises Plumb::TypeError
|
|
72
197
|
* `Types::False`
|
73
198
|
* `Types::Tuple`
|
74
199
|
* `Types::Split`
|
75
|
-
* `Types::Blank`
|
76
200
|
* `Types::Any`
|
77
201
|
* `Types::Static`
|
78
202
|
* `Types::Undefined`
|
79
203
|
* `Types::Nil`
|
80
|
-
* `Types::Present`
|
81
204
|
* `Types::Integer`
|
82
205
|
* `Types::Numeric`
|
83
206
|
* `Types::String`
|
@@ -92,6 +215,10 @@ Types::Integer.parse('10') # raises Plumb::TypeError
|
|
92
215
|
|
93
216
|
|
94
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
|
+
|
95
222
|
### `#present`
|
96
223
|
|
97
224
|
Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`)
|
@@ -112,14 +239,12 @@ nullable_str.parse('hello') # 'hello'
|
|
112
239
|
nullable_str.parse(10) # TypeError
|
113
240
|
```
|
114
241
|
|
115
|
-
Note that this
|
242
|
+
Note that this just encapsulates the following composition:
|
116
243
|
|
117
244
|
```ruby
|
118
245
|
nullable_str = Types::String | Types::Nil
|
119
246
|
```
|
120
247
|
|
121
|
-
|
122
|
-
|
123
248
|
### `#not`
|
124
249
|
|
125
250
|
Negates a type.
|
@@ -148,8 +273,6 @@ type.resolve(['a', 'a', 'b']) # Valid
|
|
148
273
|
type.resolve(['a', 'x', 'b']) # Failure
|
149
274
|
```
|
150
275
|
|
151
|
-
|
152
|
-
|
153
276
|
### `#transform`
|
154
277
|
|
155
278
|
Transform value. Requires specifying the resulting type of the value after transformation.
|
@@ -162,7 +285,42 @@ StringToInt = Types::String.transform(Integer, &:to_i)
|
|
162
285
|
StringToInteger.parse('10') # => 10
|
163
286
|
```
|
164
287
|
|
288
|
+
### `#invoke`
|
289
|
+
|
290
|
+
`#invoke` builds a Step that will invoke one or more methods on the value.
|
291
|
+
|
292
|
+
```ruby
|
293
|
+
StringToInt = Types::String.invoke(:to_i)
|
294
|
+
StringToInt.parse('100') # 100
|
295
|
+
|
296
|
+
FilteredHash = Types::Hash.invoke(:except, :foo, :bar)
|
297
|
+
FilteredHash.parse(foo: 1, bar: 2, name: 'Joe') # { name: 'Joe' }
|
298
|
+
|
299
|
+
# It works with blocks
|
300
|
+
Evens = Types::Array[Integer].invoke(:filter, &:even?)
|
301
|
+
Evens.parse([1,2,3,4,5]) # [2, 4]
|
302
|
+
|
303
|
+
# Same as
|
304
|
+
Evens = Types::Array[Integer].transform(Array) {|arr| arr.filter(&:even?) }
|
305
|
+
```
|
306
|
+
|
307
|
+
Passing an array of Symbol method names will build a chain of invocations.
|
308
|
+
|
309
|
+
```ruby
|
310
|
+
UpcaseToSym = Types::String.invoke(%i[downcase to_sym])
|
311
|
+
UpcaseToSym.parse('FOO_BAR') # :foo_bar
|
312
|
+
```
|
313
|
+
|
314
|
+
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).
|
315
|
+
|
316
|
+
Also, there's no definition-time checks that the method names are actually supported by the input values.
|
317
|
+
|
318
|
+
```ruby
|
319
|
+
type = Types::Array.invoke(:strip) # This is fine here
|
320
|
+
type.parse([1, 2]) # raises NoMethodError because Array doesn't respond to #strip
|
321
|
+
```
|
165
322
|
|
323
|
+
Use with caution.
|
166
324
|
|
167
325
|
### `#default`
|
168
326
|
|
@@ -178,7 +336,7 @@ Note that this is syntax sugar for:
|
|
178
336
|
|
179
337
|
```ruby
|
180
338
|
# A String, or if it's Undefined pipe to a static string value.
|
181
|
-
str = Types::String | (Types::Undefined >> 'nope'.freeze)
|
339
|
+
str = Types::String | (Types::Undefined >> Types::Static['nope'.freeze])
|
182
340
|
```
|
183
341
|
|
184
342
|
Meaning that you can compose your own semantics for a "default" value.
|
@@ -186,7 +344,7 @@ Meaning that you can compose your own semantics for a "default" value.
|
|
186
344
|
Example when you want to apply a default when the given value is `nil`.
|
187
345
|
|
188
346
|
```ruby
|
189
|
-
str = Types::String | (Types::Nil >> 'nope'.freeze)
|
347
|
+
str = Types::String | (Types::Nil >> Types::Static['nope'.freeze])
|
190
348
|
|
191
349
|
str.parse(nil) # 'nope'
|
192
350
|
str.parse('yup') # 'yup'
|
@@ -195,48 +353,9 @@ str.parse('yup') # 'yup'
|
|
195
353
|
Same if you want to apply a default to several cases.
|
196
354
|
|
197
355
|
```ruby
|
198
|
-
str = Types::String | ((Types::Nil | Types::Undefined) >> 'nope'.freeze)
|
199
|
-
```
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
### `#match` and `#[]`
|
204
|
-
|
205
|
-
Checks the value against a regular expression (or anything that responds to `#===`).
|
206
|
-
|
207
|
-
```ruby
|
208
|
-
email = Types::String.match(/@/)
|
209
|
-
# Same as
|
210
|
-
email = Types::String[/@/]
|
211
|
-
email.parse('hello') # fails
|
212
|
-
email.parse('hello@server.com') # 'hello@server.com'
|
213
|
-
```
|
214
|
-
|
215
|
-
It can be combined with other methods. For example to cast strings as integers, but only if they _look_ like integers.
|
216
|
-
|
217
|
-
```ruby
|
218
|
-
StringToInt = Types::String[/^\d+$/].transform(::Integer, &:to_i)
|
219
|
-
|
220
|
-
StringToInt.parse('100') # => 100
|
221
|
-
StringToInt.parse('100lol') # fails
|
222
|
-
```
|
223
|
-
|
224
|
-
It can be used with other `#===` interfaces.
|
225
|
-
|
226
|
-
```ruby
|
227
|
-
AgeBracket = Types::Integer[21..45]
|
228
|
-
|
229
|
-
AgeBracket.parse(22) # 22
|
230
|
-
AgeBracket.parse(20) # fails
|
231
|
-
|
232
|
-
# With literal values
|
233
|
-
Twenty = Types::Integer[20]
|
234
|
-
Twenty.parse(20) # 20
|
235
|
-
Twenty.parse(21) # type error
|
356
|
+
str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
|
236
357
|
```
|
237
358
|
|
238
|
-
|
239
|
-
|
240
359
|
### `#build`
|
241
360
|
|
242
361
|
Build a custom object or class.
|
@@ -251,29 +370,25 @@ UserType.parse('Joe') # #<data User name="Joe">
|
|
251
370
|
It takes an argument for a custom factory method on the object constructor.
|
252
371
|
|
253
372
|
```ruby
|
254
|
-
|
255
|
-
|
256
|
-
new(attrs)
|
257
|
-
end
|
258
|
-
end
|
373
|
+
# https://github.com/RubyMoney/monetize
|
374
|
+
require 'monetize'
|
259
375
|
|
260
|
-
|
376
|
+
StringToMoney = Types::String.build(Monetize, :parse)
|
377
|
+
money = StringToMoney.parse('£10,300.00') # #<Money fractional:1030000 currency:GBP>
|
261
378
|
```
|
262
379
|
|
263
380
|
You can also pass a block
|
264
381
|
|
265
382
|
```ruby
|
266
|
-
|
383
|
+
StringToMoney = Types::String.build(Money) { |value| Monetize.parse(value) }
|
267
384
|
```
|
268
385
|
|
269
386
|
Note that this case is identical to `#transform` with a block.
|
270
387
|
|
271
388
|
```ruby
|
272
|
-
|
389
|
+
StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
|
273
390
|
```
|
274
391
|
|
275
|
-
|
276
|
-
|
277
392
|
### `#check`
|
278
393
|
|
279
394
|
Pass the value through an arbitrary validation
|
@@ -303,8 +418,6 @@ All scalar types support this:
|
|
303
418
|
ten = Types::Integer.value(10)
|
304
419
|
```
|
305
420
|
|
306
|
-
|
307
|
-
|
308
421
|
### `#meta` and `#metadata`
|
309
422
|
|
310
423
|
Add metadata to a type
|
@@ -333,7 +446,60 @@ Types::String.transform(Integer, &:to_i).metadata[:type] # Integer
|
|
333
446
|
|
334
447
|
TODO: document custom visitors.
|
335
448
|
|
336
|
-
|
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`
|
337
503
|
|
338
504
|
```ruby
|
339
505
|
Employee = Types::Hash[
|
@@ -350,7 +516,7 @@ Company = Types::Hash[
|
|
350
516
|
result = Company.resolve(
|
351
517
|
name: 'ACME',
|
352
518
|
employees: [
|
353
|
-
|
519
|
+
{ name: 'Joe', age: 40, role: 'product' },
|
354
520
|
{ name: 'Joan', age: 38, role: 'engineer' }
|
355
521
|
]
|
356
522
|
)
|
@@ -366,7 +532,65 @@ result.valid? # false
|
|
366
532
|
result.errors[:employees][0][:age] # ["must be a Numeric"]
|
367
533
|
```
|
368
534
|
|
535
|
+
Note that you can use primitives as hash field definitions.
|
369
536
|
|
537
|
+
```ruby
|
538
|
+
User = Types::Hash[name: String, age: Integer]
|
539
|
+
```
|
540
|
+
|
541
|
+
Or to validate specific values:
|
542
|
+
|
543
|
+
```ruby
|
544
|
+
Joe = Types::Hash[name: 'Joe', age: Integer]
|
545
|
+
```
|
546
|
+
|
547
|
+
Or to validate against any `#===` interface:
|
548
|
+
|
549
|
+
```ruby
|
550
|
+
Adult = Types::Hash[name: String, age: (18..)]
|
551
|
+
# Same as
|
552
|
+
Adult = Types::Hash[name: Types::String, age: Types::Integer[18..]]
|
553
|
+
```
|
554
|
+
|
555
|
+
If you want to validate literal values, pass a `Types::Value`
|
556
|
+
|
557
|
+
```ruby
|
558
|
+
Settings = Types::Hash[age_range: Types::Value[18..]]
|
559
|
+
|
560
|
+
Settings.parse(age_range: (18..)) # Valid
|
561
|
+
Settings.parse(age_range: (20..30)) # Invalid
|
562
|
+
```
|
563
|
+
|
564
|
+
A `Types::Static` value will always resolve successfully to that value, regardless of the original payload.
|
565
|
+
|
566
|
+
```ruby
|
567
|
+
User = Types::Hash[name: Types::Static['Joe'], age: Integer]
|
568
|
+
User.parse(name: 'Rufus', age: 34) # Valid {name: 'Joe', age: 34}
|
569
|
+
```
|
570
|
+
|
571
|
+
#### Optional keys
|
572
|
+
|
573
|
+
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.
|
574
|
+
|
575
|
+
```ruby
|
576
|
+
User = Types::Hash[
|
577
|
+
age?: Integer,
|
578
|
+
name: String
|
579
|
+
]
|
580
|
+
|
581
|
+
User.parse(age: 20, name: 'Joe') # => Valid { age: 20, name: 'Joe' }
|
582
|
+
User.parse(age: '20', name: 'Joe') # => Invalid, :age is not an Integer
|
583
|
+
User.parse(name: 'Joe') #=> Valid { name: 'Joe' }
|
584
|
+
```
|
585
|
+
|
586
|
+
Note that defaults are not applied to optional keys that are missing.
|
587
|
+
|
588
|
+
```ruby
|
589
|
+
Types::Hash[
|
590
|
+
age?: Types::Integer.default(10), # does not apply default if key is missing
|
591
|
+
name: Types::String.default('Joe') # does apply default if key is missing.
|
592
|
+
]
|
593
|
+
```
|
370
594
|
|
371
595
|
#### Merging hash definitions
|
372
596
|
|
@@ -378,8 +602,6 @@ Employee = Types::Hash[name: Types::String, company: Types::String]
|
|
378
602
|
StaffMember = User + Employee # Hash[:name, :age, :company]
|
379
603
|
```
|
380
604
|
|
381
|
-
|
382
|
-
|
383
605
|
#### Hash intersections
|
384
606
|
|
385
607
|
Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
|
@@ -388,15 +610,15 @@ Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
|
|
388
610
|
intersection = User & Employee # Hash[:name]
|
389
611
|
```
|
390
612
|
|
391
|
-
|
392
|
-
|
393
613
|
#### `Types::Hash#tagged_by`
|
394
614
|
|
395
615
|
Use `#tagged_by` to resolve what definition to use based on the value of a common key.
|
396
616
|
|
617
|
+
Key used as index must be a `Types::Static`
|
618
|
+
|
397
619
|
```ruby
|
398
|
-
NameUpdatedEvent = Types::Hash[type: 'name_updated', name: Types::String]
|
399
|
-
AgeUpdatedEvent = Types::Hash[type: 'age_updated', age: Types::Integer]
|
620
|
+
NameUpdatedEvent = Types::Hash[type: Types::Static['name_updated'], name: Types::String]
|
621
|
+
AgeUpdatedEvent = Types::Hash[type: Types::Static['age_updated'], age: Types::Integer]
|
400
622
|
|
401
623
|
Events = Types::Hash.tagged_by(
|
402
624
|
:type,
|
@@ -407,7 +629,45 @@ Events = Types::Hash.tagged_by(
|
|
407
629
|
Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
|
408
630
|
```
|
409
631
|
|
632
|
+
#### `Types::Hash#inclusive`
|
633
|
+
|
634
|
+
Use `#inclusive` to preserve input keys not defined in the hash schema.
|
635
|
+
|
636
|
+
```ruby
|
637
|
+
hash = Types::Hash[age: Types::Lax::Integer].inclusive
|
638
|
+
|
639
|
+
# Only :age, is coerced and validated, all other keys are preserved as-is
|
640
|
+
hash.parse(age: '30', name: 'Joe', last_name: 'Bloggs') # { age: 30, name: 'Joe', last_name: 'Bloggs' }
|
641
|
+
```
|
642
|
+
|
643
|
+
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.
|
644
|
+
|
645
|
+
```ruby
|
646
|
+
# Front-end definition does structural validation
|
647
|
+
Front = Types::Hash[price: Integer, name: String, category: String]
|
648
|
+
|
649
|
+
# Turn an Integer into a Money instance
|
650
|
+
IntToMoney = Types::Integer.build(Money)
|
651
|
+
|
652
|
+
# Backend definition turns :price into a Money object, leaves other keys as-is
|
653
|
+
Back = Types::Hash[price: IntToMoney].inclusive
|
654
|
+
|
655
|
+
# Compose the pipeline
|
656
|
+
InputHandler = Front >> Back
|
657
|
+
|
658
|
+
InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
|
659
|
+
# => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: 'smartphone' }
|
660
|
+
```
|
661
|
+
|
662
|
+
#### `Types::Hash#filtered`
|
410
663
|
|
664
|
+
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.
|
665
|
+
|
666
|
+
```ruby
|
667
|
+
User = Types::Hash[name: String, age: Integer]
|
668
|
+
User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
|
669
|
+
User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
|
670
|
+
```
|
411
671
|
|
412
672
|
### Hash maps
|
413
673
|
|
@@ -420,6 +680,37 @@ currencies.parse(usd: 'USD', gbp: 'GBP') # Ok
|
|
420
680
|
currencies.parse('usd' => 'USD') # Error. Keys must be Symbols
|
421
681
|
```
|
422
682
|
|
683
|
+
Like other types, hash maps accept primitive types as keys and values:
|
684
|
+
|
685
|
+
```ruby
|
686
|
+
currencies = Types::Hash[Symbol, String]
|
687
|
+
```
|
688
|
+
|
689
|
+
And any `#===` interface as values, too:
|
690
|
+
|
691
|
+
```ruby
|
692
|
+
names_and_emails = Types::Hash[String, /\w+@\w+/]
|
693
|
+
|
694
|
+
names_and_emails.parse('Joe' => 'joe@server.com', 'Rufus' => 'rufus')
|
695
|
+
```
|
696
|
+
|
697
|
+
Use `Types::Value` to validate specific values (using `#==`)
|
698
|
+
|
699
|
+
```ruby
|
700
|
+
names_and_ones = Types::Hash[String, Types::Integer.value(1)]
|
701
|
+
```
|
702
|
+
|
703
|
+
#### `#filtered`
|
704
|
+
|
705
|
+
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.
|
706
|
+
|
707
|
+
```ruby
|
708
|
+
# Filter the ENV for all keys starting with S3_*
|
709
|
+
S3Config = Types::Hash[/^S3_\w+/, Types::Any].filtered
|
710
|
+
|
711
|
+
S3Config.parse(ENV.to_h) # { 'S3_BUCKET' => 'foo', 'S3_REGION' => 'us-east-1' }
|
712
|
+
```
|
713
|
+
|
423
714
|
|
424
715
|
|
425
716
|
### `Types::Array`
|
@@ -429,19 +720,54 @@ names = Types::Array[Types::String.present]
|
|
429
720
|
names_or_ages = Types::Array[Types::String.present | Types::Integer[21..]]
|
430
721
|
```
|
431
722
|
|
723
|
+
Arrays support primitive classes, or any `#===` interface:
|
724
|
+
|
725
|
+
```ruby
|
726
|
+
strings = Types::Array[String]
|
727
|
+
emails = Types::Array[/@/]
|
728
|
+
# Similar to
|
729
|
+
emails = Types::Array[Types::String[/@/]]
|
730
|
+
```
|
731
|
+
|
732
|
+
Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching agains the regular expression.
|
733
|
+
|
432
734
|
#### Concurrent arrays
|
433
735
|
|
434
736
|
Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
|
435
737
|
|
436
738
|
```ruby
|
437
|
-
ImageDownload = Types::URL >> ->(result) {
|
739
|
+
ImageDownload = Types::URL >> ->(result) {
|
740
|
+
resp = HTTP.get(result.value)
|
741
|
+
if (200...300).include?(resp.status)
|
742
|
+
result.valid(resp.body)
|
743
|
+
else
|
744
|
+
result.invalid(error: resp.status)
|
745
|
+
end
|
746
|
+
}
|
438
747
|
Images = Types::Array[ImageDownload].concurrent
|
439
748
|
|
440
749
|
# Images are downloaded concurrently and returned in order.
|
441
750
|
Images.parse(['https://images.com/1.png', 'https://images.com/2.png'])
|
442
751
|
```
|
443
752
|
|
444
|
-
TODO: pluggable
|
753
|
+
TODO: pluggable concurrency engines (Async?)
|
754
|
+
|
755
|
+
#### `#stream`
|
756
|
+
|
757
|
+
Turn an Array definition into an enumerator that yields each element wrapped in `Result::Valid` or `Result::Invalid`.
|
758
|
+
|
759
|
+
See `Types::Stream` below for more.
|
760
|
+
|
761
|
+
#### `#filtered`
|
762
|
+
|
763
|
+
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.
|
764
|
+
|
765
|
+
```ruby
|
766
|
+
j_names = Types::Array[Types::String[/^j/]].filtered
|
767
|
+
j_names.parse(%w[james ismael joe toby joan isabel]) # ["james", "joe", "joan"]
|
768
|
+
```
|
769
|
+
|
770
|
+
|
445
771
|
|
446
772
|
### `Types::Tuple`
|
447
773
|
|
@@ -461,70 +787,81 @@ Error = Types::Tuple[:error, Types::String.present]
|
|
461
787
|
Status = Ok | Error
|
462
788
|
```
|
463
789
|
|
790
|
+
... Or any `#===` interface
|
464
791
|
|
792
|
+
```ruby
|
793
|
+
NameAndEmail = Types::Tuple[String, /@/]
|
794
|
+
```
|
465
795
|
|
466
|
-
|
796
|
+
As before, use `Types::Value` to check against literal values using `#==`
|
467
797
|
|
468
|
-
|
798
|
+
```ruby
|
799
|
+
NameAndRegex = Types::Tuple[String, Types::Value[/@/]]
|
800
|
+
```
|
469
801
|
|
470
|
-
### Plumb::Pipeline
|
471
802
|
|
472
|
-
TODO
|
473
803
|
|
474
|
-
###
|
804
|
+
### `Types::Stream`
|
475
805
|
|
476
|
-
|
806
|
+
`Types::Stream` defines an enumerator that validates/coerces each element as it iterates.
|
477
807
|
|
478
|
-
|
808
|
+
This example streams a CSV file and validates rows as they are consumed.
|
479
809
|
|
480
810
|
```ruby
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
811
|
+
require 'csv'
|
812
|
+
|
813
|
+
Row = Types::Tuple[Types::String.present, Types:Lax::Integer]
|
814
|
+
Stream = Types::Stream[Row]
|
815
|
+
|
816
|
+
data = CSV.new(File.new('./big-file.csv')).each # An Enumerator
|
817
|
+
# stream is an Enumerator that yields rows wrapped in[Result::Valid] or [Result::Invalid]
|
818
|
+
stream = Stream.parse(data)
|
819
|
+
stream.each.with_index(1) do |result, line|
|
820
|
+
if result.valid?
|
821
|
+
p result.value
|
822
|
+
else
|
823
|
+
p ["row at line #{line} is invalid: ", result.errors]
|
824
|
+
end
|
825
|
+
end
|
485
826
|
```
|
486
827
|
|
828
|
+
#### `Types::Stream#filtered`
|
487
829
|
|
488
|
-
|
830
|
+
Use `#filtered` to turn a `Types::Stream` into a stream that only yields valid elements.
|
489
831
|
|
490
832
|
```ruby
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
833
|
+
ValidElements = Types::Stream[Row].filtered
|
834
|
+
ValidElements.parse(data).each do |valid_row|
|
835
|
+
p valid_row
|
836
|
+
end
|
495
837
|
```
|
496
838
|
|
497
|
-
|
839
|
+
#### `Types::Array#stream`
|
840
|
+
|
841
|
+
A `Types::Array` definition can be turned into a stream.
|
498
842
|
|
499
843
|
```ruby
|
500
|
-
|
501
|
-
|
502
|
-
|
844
|
+
Arr = Types::Array[Integer]
|
845
|
+
Str = Arr.stream
|
846
|
+
|
847
|
+
Str.parse(data).each do |row|
|
848
|
+
row.valid?
|
849
|
+
row.errors
|
850
|
+
row.value
|
851
|
+
end
|
503
852
|
```
|
504
853
|
|
505
|
-
|
854
|
+
### Plumb::Schema
|
506
855
|
|
507
|
-
|
508
|
-
require 'money'
|
856
|
+
TODO
|
509
857
|
|
510
|
-
|
511
|
-
include Plumb::Types
|
512
|
-
|
513
|
-
Money = Any[::Money]
|
514
|
-
IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') }
|
515
|
-
StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i)
|
516
|
-
USD = Money.check { |amount| amount.currency.code == 'UDS' }
|
517
|
-
ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') }
|
518
|
-
|
519
|
-
FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD)
|
520
|
-
end
|
858
|
+
### Plumb::Pipeline
|
521
859
|
|
522
|
-
|
523
|
-
FlexibleUSD.parse(1000) # Money(USD 10.00)
|
524
|
-
FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
|
525
|
-
```
|
860
|
+
TODO
|
526
861
|
|
862
|
+
### Plumb::Struct
|
527
863
|
|
864
|
+
TODO
|
528
865
|
|
529
866
|
### Recursive types
|
530
867
|
|
@@ -559,10 +896,6 @@ LinkedList = Types::Hash[
|
|
559
896
|
|
560
897
|
|
561
898
|
|
562
|
-
### Type-specific Rules
|
563
|
-
|
564
|
-
TODO
|
565
|
-
|
566
899
|
### Custom types
|
567
900
|
|
568
901
|
Compose procs or lambdas directly
|
@@ -587,8 +920,99 @@ end
|
|
587
920
|
MyType = Types::String >> Greeting.new('Hola')
|
588
921
|
```
|
589
922
|
|
590
|
-
|
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.
|
591
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.
|
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
|
+
```
|
592
1016
|
|
593
1017
|
### JSON Schema
|
594
1018
|
|
@@ -611,6 +1035,22 @@ json_schema = Plumb::JSONSchemaVisitor.call(User)
|
|
611
1035
|
}
|
612
1036
|
```
|
613
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
|
+
|
614
1054
|
|
615
1055
|
|
616
1056
|
## Development
|