plumb 0.0.1 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Plumb
2
2
 
3
- Composable data validation and coercion in Ruby. WiP.
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 is syntax sugar for
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
- class User
255
- def self.create(attrs)
256
- new(attrs)
257
- end
258
- end
373
+ # https://github.com/RubyMoney/monetize
374
+ require 'monetize'
259
375
 
260
- UserType = Types::String.build(User, :create)
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
- UserType = Types::String.build(User) { |name| User.new(name) }
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
- UserType = Types::String.transform(User) { |name| User.new(name) }
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
- ## `Types::Hash`
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
- { name: 'Joe', age: 40, role: 'product' },
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) { HTTP.get(result.value) }
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 concurrently engines (Async?)
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
- ### Plumb::Schema
796
+ As before, use `Types::Value` to check against literal values using `#==`
467
797
 
468
- TODO
798
+ ```ruby
799
+ NameAndRegex = Types::Tuple[String, Types::Value[/@/]]
800
+ ```
469
801
 
470
- ### Plumb::Pipeline
471
802
 
472
- TODO
473
803
 
474
- ### Plumb::Struct
804
+ ### `Types::Stream`
475
805
 
476
- TODO
806
+ `Types::Stream` defines an enumerator that validates/coerces each element as it iterates.
477
807
 
478
- ## Composing types with `#>>` ("And")
808
+ This example streams a CSV file and validates rows as they are consumed.
479
809
 
480
810
  ```ruby
481
- Email = Types::String.match(/@/)
482
- Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") }
483
-
484
- Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
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
- ## Disjunction with `#|` ("Or")
830
+ Use `#filtered` to turn a `Types::Stream` into a stream that only yields valid elements.
489
831
 
490
832
  ```ruby
491
- StringOrInt = Types::String | Types::Integer
492
- StringOrInt.parse('hello') # "hello"
493
- StringOrInt.parse(10) # 10
494
- StringOrInt.parse({}) # raises Plumb::TypeError
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
- Custom default value logic for non-emails
839
+ #### `Types::Array#stream`
840
+
841
+ A `Types::Array` definition can be turned into a stream.
498
842
 
499
843
  ```ruby
500
- EmailOrDefault = Greeting | Types::Static['no email']
501
- EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
502
- EmailOrDefault.parse('nope') # "no email"
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
- ## Composing with `#>>` and `#|`
854
+ ### Plumb::Schema
506
855
 
507
- ```ruby
508
- require 'money'
856
+ TODO
509
857
 
510
- module Types
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
- FlexibleUSD.parse('1000') # Money(USD 10.00)
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
- You can return `result.invalid(errors: "this is invalid")` to halt processing.
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