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.
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