plumb 0.0.1 → 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/.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
|