plumb 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +636 -129
- data/examples/concurrent_downloads.rb +3 -3
- data/examples/env_config.rb +122 -0
- data/examples/event_registry.rb +120 -0
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/and.rb +4 -3
- data/lib/plumb/any_class.rb +4 -4
- data/lib/plumb/array_class.rb +8 -5
- data/lib/plumb/attributes.rb +262 -0
- data/lib/plumb/build.rb +4 -3
- data/lib/plumb/{steppable.rb → composable.rb} +85 -67
- data/lib/plumb/decorator.rb +57 -0
- data/lib/plumb/deferred.rb +1 -1
- data/lib/plumb/hash_class.rb +20 -11
- data/lib/plumb/hash_map.rb +8 -6
- data/lib/plumb/interface_class.rb +6 -2
- data/lib/plumb/json_schema_visitor.rb +97 -36
- data/lib/plumb/match_class.rb +7 -7
- data/lib/plumb/metadata.rb +5 -1
- data/lib/plumb/metadata_visitor.rb +18 -38
- data/lib/plumb/not.rb +4 -3
- data/lib/plumb/or.rb +10 -4
- data/lib/plumb/pipeline.rb +6 -5
- data/lib/plumb/policies.rb +81 -0
- data/lib/plumb/policy.rb +38 -0
- data/lib/plumb/schema.rb +13 -12
- data/lib/plumb/static_class.rb +4 -3
- data/lib/plumb/step.rb +4 -3
- data/lib/plumb/stream_class.rb +8 -7
- data/lib/plumb/tagged_hash.rb +10 -10
- data/lib/plumb/transform.rb +4 -3
- data/lib/plumb/tuple_class.rb +8 -8
- data/lib/plumb/type_registry.rb +5 -2
- data/lib/plumb/types.rb +119 -23
- data/lib/plumb/value_class.rb +4 -3
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +12 -1
- data/lib/plumb.rb +59 -2
- metadata +12 -7
- data/lib/plumb/rules.rb +0 -102
data/README.md
CHANGED
@@ -12,11 +12,15 @@ For a description of the core architecture you can read [this article](https://i
|
|
12
12
|
|
13
13
|
## Installation
|
14
14
|
|
15
|
-
|
15
|
+
Install in your environment with `gem install plumb`, or in your `Gemfile` with
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
gem 'plumb'
|
19
|
+
```
|
16
20
|
|
17
21
|
## Usage
|
18
22
|
|
19
|
-
### Include base types
|
23
|
+
### Include base types.
|
20
24
|
|
21
25
|
Include base types in your own namespace:
|
22
26
|
|
@@ -39,6 +43,54 @@ result.valid? # false
|
|
39
43
|
result.errors # ""
|
40
44
|
```
|
41
45
|
|
46
|
+
Note that this is not mandatory. You can also work with the `Plumb::Types` module directly, ex. `Plumb::Types::String`
|
47
|
+
|
48
|
+
### Specialize your types with `#[]`
|
49
|
+
|
50
|
+
Use `#[]` to make your types match a class.
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
module Types
|
54
|
+
include Plumb::Types
|
55
|
+
|
56
|
+
String = Any[::String]
|
57
|
+
Integer = Any[::Integer]
|
58
|
+
end
|
59
|
+
|
60
|
+
Types::String.parse("hello") # => "hello"
|
61
|
+
Types::String.parse(10) # raises "Must be a String" (Plumb::TypeError)
|
62
|
+
```
|
63
|
+
|
64
|
+
Plumb ships with basic types already defined, such as `Types::String` and `Types::Integer`. See the full list below.
|
65
|
+
|
66
|
+
The `#[]` method is not just for classes. It works with anything that responds to `#===`
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
# Match against a regex
|
70
|
+
Email = Types::String[/@/] # ie Types::Any[String][/@/]
|
71
|
+
|
72
|
+
Email.parse('hello') # fails
|
73
|
+
Email.parse('hello@server.com') # 'hello@server.com'
|
74
|
+
|
75
|
+
# Or a Range
|
76
|
+
AdultAge = Types::Integer[18..]
|
77
|
+
AdultAge.parse(20) # 20
|
78
|
+
AdultAge.parse(17) # raises "Must be within 18.."" (Plumb::TypeError)
|
79
|
+
|
80
|
+
# Or literal values
|
81
|
+
Twenty = Types::Integer[20]
|
82
|
+
Twenty.parse(20) # 20
|
83
|
+
Twenty.parse(21) # type error
|
84
|
+
```
|
85
|
+
|
86
|
+
It can be combined with other methods. For example to cast strings as integers, but only if they _look_ like integers.
|
87
|
+
|
88
|
+
```ruby
|
89
|
+
StringToInt = Types::String[/^\d+$/].transform(::Integer, &:to_i)
|
90
|
+
|
91
|
+
StringToInt.parse('100') # => 100
|
92
|
+
StringToInt.parse('100lol') # fails
|
93
|
+
```
|
42
94
|
|
43
95
|
### `#resolve(value) => Result`
|
44
96
|
|
@@ -55,8 +107,6 @@ result.value # '10'
|
|
55
107
|
result.errors # 'must be an Integer'
|
56
108
|
```
|
57
109
|
|
58
|
-
|
59
|
-
|
60
110
|
### `#parse(value) => value`
|
61
111
|
|
62
112
|
`#parse` takes an input value and returns the parsed/coerced value if successful. or it raises an exception if failed.
|
@@ -68,7 +118,101 @@ Types::Integer.parse('10') # raises Plumb::TypeError
|
|
68
118
|
|
69
119
|
|
70
120
|
|
71
|
-
|
121
|
+
### Composite types
|
122
|
+
|
123
|
+
Some built-in types such as `Types::Array` and `Types::Hash` allow defininig array or hash data structures composed of other types.
|
124
|
+
|
125
|
+
```ruby
|
126
|
+
# A user hash
|
127
|
+
User = Types::Hash[name: Types::String, email: Email, age: AdultAge]
|
128
|
+
|
129
|
+
# An array of User hashes
|
130
|
+
Users = Types::Array[User]
|
131
|
+
|
132
|
+
joe = User.parse({ name: 'Joe', email: 'joe@email.com', age: 20}) # returns valid hash
|
133
|
+
Users.parse([joe]) # returns valid array of user hashes
|
134
|
+
```
|
135
|
+
|
136
|
+
More about [Types::Array](#typeshash) and [Types::Array](#typesarray). There's also [tuples](#typestuple), [hash maps](#hash-maps) and [data structs](#typesdata), and it's possible to create your own composite types.
|
137
|
+
|
138
|
+
### Type composition
|
139
|
+
|
140
|
+
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.
|
141
|
+
|
142
|
+
#### Composing types with `#>>` ("And")
|
143
|
+
|
144
|
+
```ruby
|
145
|
+
Email = Types::String[/@/]
|
146
|
+
# You can compose procs and lambdas, or other types.
|
147
|
+
Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") }
|
148
|
+
|
149
|
+
Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
150
|
+
```
|
151
|
+
|
152
|
+
Similar to Ruby's built-in [function composition](https://thoughtbot.com/blog/proc-composition-in-ruby), `#>>` pipes the output of a "type" to the input of the next type. However, if a type returns an "invalid" result, the chain is halted there and subsequent steps are never run.
|
153
|
+
|
154
|
+
In other words, `A >> B` means "if A succeeds, pass its result to B. Otherwise return A's failed result."
|
155
|
+
|
156
|
+
#### Disjunction with `#|` ("Or")
|
157
|
+
|
158
|
+
`A | B` means "if A returns a valid result, return that. Otherwise try B with the original input."
|
159
|
+
|
160
|
+
```ruby
|
161
|
+
StringOrInt = Types::String | Types::Integer
|
162
|
+
StringOrInt.parse('hello') # "hello"
|
163
|
+
StringOrInt.parse(10) # 10
|
164
|
+
StringOrInt.parse({}) # raises Plumb::TypeError
|
165
|
+
```
|
166
|
+
|
167
|
+
Custom default value logic for non-emails
|
168
|
+
|
169
|
+
```ruby
|
170
|
+
EmailOrDefault = Greeting | Types::Static['no email']
|
171
|
+
EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
172
|
+
EmailOrDefault.parse('nope') # "no email"
|
173
|
+
```
|
174
|
+
|
175
|
+
#### Composing with `#>>` and `#|`
|
176
|
+
|
177
|
+
Combine `#>>` and `#|` to compose branching workflows, or types that accept and output several possible data types.
|
178
|
+
|
179
|
+
`((A >> B) | C | D) >> E)`
|
180
|
+
|
181
|
+
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. It also shows some of the built-in [policies](#policies) or helpers.
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
require 'money'
|
185
|
+
|
186
|
+
module Types
|
187
|
+
include Plumb::Types
|
188
|
+
|
189
|
+
# Match any Money instance
|
190
|
+
Money = Any[::Money]
|
191
|
+
|
192
|
+
# Transform Integers into Money instances
|
193
|
+
IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') }
|
194
|
+
|
195
|
+
# Transform integer-looking Strings into Integers
|
196
|
+
StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i)
|
197
|
+
|
198
|
+
# Validate that a Money instance is USD
|
199
|
+
USD = Money.check { |amount| amount.currency.code == 'UDS' }
|
200
|
+
|
201
|
+
# Exchange a non-USD Money instance into USD
|
202
|
+
ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') }
|
203
|
+
|
204
|
+
# Compose a pipeline that accepts Strings, Integers or Money and returns USD money.
|
205
|
+
FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD)
|
206
|
+
end
|
207
|
+
|
208
|
+
FlexibleUSD.parse('1000') # Money(USD 10.00)
|
209
|
+
FlexibleUSD.parse(1000) # Money(USD 10.00)
|
210
|
+
FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
|
211
|
+
```
|
212
|
+
|
213
|
+
You can see more use cases in [the examples directory](https://github.com/ismasan/plumb/tree/main/examples)
|
214
|
+
|
215
|
+
### Built-in types
|
72
216
|
|
73
217
|
* `Types::Value`
|
74
218
|
* `Types::Array`
|
@@ -78,13 +222,10 @@ Types::Integer.parse('10') # raises Plumb::TypeError
|
|
78
222
|
* `Types::Interface`
|
79
223
|
* `Types::False`
|
80
224
|
* `Types::Tuple`
|
81
|
-
* `Types::Split`
|
82
|
-
* `Types::Blank`
|
83
225
|
* `Types::Any`
|
84
226
|
* `Types::Static`
|
85
227
|
* `Types::Undefined`
|
86
228
|
* `Types::Nil`
|
87
|
-
* `Types::Present`
|
88
229
|
* `Types::Integer`
|
89
230
|
* `Types::Numeric`
|
90
231
|
* `Types::String`
|
@@ -97,9 +238,13 @@ Types::Integer.parse('10') # raises Plumb::TypeError
|
|
97
238
|
* `Types::Forms::True`
|
98
239
|
* `Types::Forms::False`
|
99
240
|
|
241
|
+
TODO: date and datetime, UUIDs, Email, others.
|
100
242
|
|
243
|
+
### Policies
|
101
244
|
|
102
|
-
|
245
|
+
Policies are helpers that encapsulate common compositions. Plumb ships with some handy ones, listed below, and you can also define your own.
|
246
|
+
|
247
|
+
#### `#present`
|
103
248
|
|
104
249
|
Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`)
|
105
250
|
|
@@ -108,7 +253,7 @@ Types::String.present.resolve('') # Failure with errors
|
|
108
253
|
Types::Array[Types::String].resolve([]) # Failure with errors
|
109
254
|
```
|
110
255
|
|
111
|
-
|
256
|
+
#### `#nullable`
|
112
257
|
|
113
258
|
Allow `nil` values.
|
114
259
|
|
@@ -119,15 +264,13 @@ nullable_str.parse('hello') # 'hello'
|
|
119
264
|
nullable_str.parse(10) # TypeError
|
120
265
|
```
|
121
266
|
|
122
|
-
Note that this
|
267
|
+
Note that this just encapsulates the following composition:
|
123
268
|
|
124
269
|
```ruby
|
125
270
|
nullable_str = Types::String | Types::Nil
|
126
271
|
```
|
127
272
|
|
128
|
-
|
129
|
-
|
130
|
-
### `#not`
|
273
|
+
#### `#not`
|
131
274
|
|
132
275
|
Negates a type.
|
133
276
|
```ruby
|
@@ -137,7 +280,7 @@ NotEmail.parse('hello') # "hello"
|
|
137
280
|
NotEmail.parse('hello@server.com') # error
|
138
281
|
```
|
139
282
|
|
140
|
-
|
283
|
+
#### `#options`
|
141
284
|
|
142
285
|
Sets allowed options for value.
|
143
286
|
|
@@ -155,9 +298,7 @@ type.resolve(['a', 'a', 'b']) # Valid
|
|
155
298
|
type.resolve(['a', 'x', 'b']) # Failure
|
156
299
|
```
|
157
300
|
|
158
|
-
|
159
|
-
|
160
|
-
### `#transform`
|
301
|
+
#### `#transform`
|
161
302
|
|
162
303
|
Transform value. Requires specifying the resulting type of the value after transformation.
|
163
304
|
|
@@ -169,7 +310,7 @@ StringToInt = Types::String.transform(Integer, &:to_i)
|
|
169
310
|
StringToInteger.parse('10') # => 10
|
170
311
|
```
|
171
312
|
|
172
|
-
|
313
|
+
#### `#invoke`
|
173
314
|
|
174
315
|
`#invoke` builds a Step that will invoke one or more methods on the value.
|
175
316
|
|
@@ -195,7 +336,7 @@ UpcaseToSym = Types::String.invoke(%i[downcase to_sym])
|
|
195
336
|
UpcaseToSym.parse('FOO_BAR') # :foo_bar
|
196
337
|
```
|
197
338
|
|
198
|
-
|
339
|
+
Note, as opposed to `#transform`, this helper does not register a type in `#metadata[:type]`, which can be valuable for introspection or documentation (ex. JSON Schema).
|
199
340
|
|
200
341
|
Also, there's no definition-time checks that the method names are actually supported by the input values.
|
201
342
|
|
@@ -206,7 +347,7 @@ type.parse([1, 2]) # raises NoMethodError because Array doesn't respond to #stri
|
|
206
347
|
|
207
348
|
Use with caution.
|
208
349
|
|
209
|
-
|
350
|
+
#### `#default`
|
210
351
|
|
211
352
|
Default value when no value given (ie. when key is missing in Hash payloads. See `Types::Hash` below).
|
212
353
|
|
@@ -240,46 +381,7 @@ Same if you want to apply a default to several cases.
|
|
240
381
|
str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
|
241
382
|
```
|
242
383
|
|
243
|
-
|
244
|
-
|
245
|
-
### `#match` and `#[]`
|
246
|
-
|
247
|
-
Checks the value against a regular expression (or anything that responds to `#===`).
|
248
|
-
|
249
|
-
```ruby
|
250
|
-
email = Types::String.match(/@/)
|
251
|
-
# Same as
|
252
|
-
email = Types::String[/@/]
|
253
|
-
email.parse('hello') # fails
|
254
|
-
email.parse('hello@server.com') # 'hello@server.com'
|
255
|
-
```
|
256
|
-
|
257
|
-
It can be combined with other methods. For example to cast strings as integers, but only if they _look_ like integers.
|
258
|
-
|
259
|
-
```ruby
|
260
|
-
StringToInt = Types::String[/^\d+$/].transform(::Integer, &:to_i)
|
261
|
-
|
262
|
-
StringToInt.parse('100') # => 100
|
263
|
-
StringToInt.parse('100lol') # fails
|
264
|
-
```
|
265
|
-
|
266
|
-
It can be used with other `#===` interfaces.
|
267
|
-
|
268
|
-
```ruby
|
269
|
-
AgeBracket = Types::Integer[21..45]
|
270
|
-
|
271
|
-
AgeBracket.parse(22) # 22
|
272
|
-
AgeBracket.parse(20) # fails
|
273
|
-
|
274
|
-
# With literal values
|
275
|
-
Twenty = Types::Integer[20]
|
276
|
-
Twenty.parse(20) # 20
|
277
|
-
Twenty.parse(21) # type error
|
278
|
-
```
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
### `#build`
|
384
|
+
#### `#build`
|
283
385
|
|
284
386
|
Build a custom object or class.
|
285
387
|
|
@@ -312,9 +414,7 @@ Note that this case is identical to `#transform` with a block.
|
|
312
414
|
StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
|
313
415
|
```
|
314
416
|
|
315
|
-
|
316
|
-
|
317
|
-
### `#check`
|
417
|
+
#### `#check`
|
318
418
|
|
319
419
|
Pass the value through an arbitrary validation
|
320
420
|
|
@@ -324,9 +424,7 @@ type.parse('Role: Manager') # 'Role: Manager'
|
|
324
424
|
type.parse('Manager') # fails
|
325
425
|
```
|
326
426
|
|
327
|
-
|
328
|
-
|
329
|
-
### `#value`
|
427
|
+
#### `#value`
|
330
428
|
|
331
429
|
Constrain a type to a specific value. Compares with `#==`
|
332
430
|
|
@@ -343,21 +441,21 @@ All scalar types support this:
|
|
343
441
|
ten = Types::Integer.value(10)
|
344
442
|
```
|
345
443
|
|
346
|
-
|
347
|
-
|
348
|
-
### `#meta` and `#metadata`
|
444
|
+
#### `#metadata`
|
349
445
|
|
350
446
|
Add metadata to a type
|
351
447
|
|
352
448
|
```ruby
|
353
|
-
|
449
|
+
# A new type with metadata
|
450
|
+
type = Types::String.metadata(description: 'A long text')
|
451
|
+
# Read a type's metadata
|
354
452
|
type.metadata[:description] # 'A long text'
|
355
453
|
```
|
356
454
|
|
357
455
|
`#metadata` combines keys from type compositions.
|
358
456
|
|
359
457
|
```ruby
|
360
|
-
type = Types::String.
|
458
|
+
type = Types::String.metadata(description: 'A long text') >> Types::String.match(/@/).metadata(note: 'An email address')
|
361
459
|
type.metadata[:description] # 'A long text'
|
362
460
|
type.metadata[:note] # 'An email address'
|
363
461
|
```
|
@@ -373,7 +471,60 @@ Types::String.transform(Integer, &:to_i).metadata[:type] # Integer
|
|
373
471
|
|
374
472
|
TODO: document custom visitors.
|
375
473
|
|
376
|
-
|
474
|
+
### Other policies
|
475
|
+
|
476
|
+
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.
|
477
|
+
|
478
|
+
#### `:respond_to`
|
479
|
+
|
480
|
+
Similar to `Types::Interface`, this is a quick way to assert that a value supports one or more methods.
|
481
|
+
|
482
|
+
```ruby
|
483
|
+
List = Types::Any.policy(respond_to: :each)
|
484
|
+
# or
|
485
|
+
List = Types::Any.policy(respond_to: [:each, :[], :size)
|
486
|
+
```
|
487
|
+
|
488
|
+
#### `:excluded_from`
|
489
|
+
|
490
|
+
The opposite of `#options`, this policy validates that the value _is not_ included in a list.
|
491
|
+
|
492
|
+
```ruby
|
493
|
+
Name = Types::String.policy(excluded_from: ['Joe', 'Joan'])
|
494
|
+
```
|
495
|
+
|
496
|
+
#### `:size`
|
497
|
+
|
498
|
+
Works for any value that responds to `#size` and validates that the value's size matches the argument.
|
499
|
+
|
500
|
+
```ruby
|
501
|
+
LimitedArray = Types::Array[String].policy(size: 10)
|
502
|
+
LimitedString = Types::String.policy(size: 10)
|
503
|
+
LimitedSet = Types::Any[Set].policy(size: 10)
|
504
|
+
```
|
505
|
+
|
506
|
+
The size is matched via `#===`, so ranges also work.
|
507
|
+
|
508
|
+
```ruby
|
509
|
+
Password = Types::String.policy(size: 10..20)
|
510
|
+
```
|
511
|
+
|
512
|
+
#### `:split` (strings only)
|
513
|
+
|
514
|
+
Splits string values by a separator (default: `,`).
|
515
|
+
|
516
|
+
```ruby
|
517
|
+
CSVLine = Types::String.split
|
518
|
+
CSVLine.parse('a,b,c') # => ['a', 'b', 'c']
|
519
|
+
|
520
|
+
# Or, with custom separator
|
521
|
+
CSVLine = Types::String.split(/\s*;\s*/)
|
522
|
+
CSVLine.parse('a;b;c') # => ['a', 'b', 'c']
|
523
|
+
```
|
524
|
+
|
525
|
+
|
526
|
+
|
527
|
+
### `Types::Hash`
|
377
528
|
|
378
529
|
```ruby
|
379
530
|
Employee = Types::Hash[
|
@@ -466,8 +617,6 @@ Types::Hash[
|
|
466
617
|
]
|
467
618
|
```
|
468
619
|
|
469
|
-
|
470
|
-
|
471
620
|
#### Merging hash definitions
|
472
621
|
|
473
622
|
Use `Types::Hash#+` to merge two definitions. Keys in the second hash override the first one's.
|
@@ -478,8 +627,6 @@ Employee = Types::Hash[name: Types::String, company: Types::String]
|
|
478
627
|
StaffMember = User + Employee # Hash[:name, :age, :company]
|
479
628
|
```
|
480
629
|
|
481
|
-
|
482
|
-
|
483
630
|
#### Hash intersections
|
484
631
|
|
485
632
|
Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
|
@@ -488,17 +635,13 @@ Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
|
|
488
635
|
intersection = User & Employee # Hash[:name]
|
489
636
|
```
|
490
637
|
|
491
|
-
|
492
|
-
|
493
638
|
#### `Types::Hash#tagged_by`
|
494
639
|
|
495
640
|
Use `#tagged_by` to resolve what definition to use based on the value of a common key.
|
496
641
|
|
497
|
-
Key used as index must be a `Types::Static`
|
498
|
-
|
499
642
|
```ruby
|
500
|
-
NameUpdatedEvent = Types::Hash[type:
|
501
|
-
AgeUpdatedEvent = Types::Hash[type:
|
643
|
+
NameUpdatedEvent = Types::Hash[type: 'name_updated', name: Types::String]
|
644
|
+
AgeUpdatedEvent = Types::Hash[type: 'age_updated', age: Types::Integer]
|
502
645
|
|
503
646
|
Events = Types::Hash.tagged_by(
|
504
647
|
:type,
|
@@ -509,8 +652,6 @@ Events = Types::Hash.tagged_by(
|
|
509
652
|
Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
|
510
653
|
```
|
511
654
|
|
512
|
-
|
513
|
-
|
514
655
|
#### `Types::Hash#inclusive`
|
515
656
|
|
516
657
|
Use `#inclusive` to preserve input keys not defined in the hash schema.
|
@@ -546,13 +687,11 @@ InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
|
|
546
687
|
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.
|
547
688
|
|
548
689
|
```ruby
|
549
|
-
User = Types::Hash[name: String, age: Integer]
|
690
|
+
User = Types::Hash[name: String, age: Integer].filtered
|
550
691
|
User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
|
551
692
|
User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
|
552
693
|
```
|
553
694
|
|
554
|
-
|
555
|
-
|
556
695
|
### Hash maps
|
557
696
|
|
558
697
|
You can also use Hash syntax to define a hash map with specific types for all keys and values:
|
@@ -613,7 +752,7 @@ emails = Types::Array[/@/]
|
|
613
752
|
emails = Types::Array[Types::String[/@/]]
|
614
753
|
```
|
615
754
|
|
616
|
-
Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching
|
755
|
+
Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching against the regular expression.
|
617
756
|
|
618
757
|
#### Concurrent arrays
|
619
758
|
|
@@ -735,68 +874,203 @@ Str.parse(data).each do |row|
|
|
735
874
|
end
|
736
875
|
```
|
737
876
|
|
738
|
-
###
|
877
|
+
### Types::Data
|
739
878
|
|
740
|
-
|
879
|
+
`Types::Data` provides a superclass to define **inmutable** structs or value objects with typed / coercible attributes.
|
741
880
|
|
742
|
-
|
881
|
+
#### `[]` Syntax
|
743
882
|
|
744
|
-
|
883
|
+
The `[]` syntax is a short-hand for struct definition.
|
884
|
+
Like `Plumb::Types::Hash`, suffixing a key with `?` makes it optional.
|
745
885
|
|
746
|
-
|
886
|
+
```ruby
|
887
|
+
Person = Types::Data[name: String, age?: Integer]
|
888
|
+
person = Person.new(name: 'Jane')
|
889
|
+
```
|
747
890
|
|
748
|
-
|
891
|
+
This syntax creates subclasses too.
|
749
892
|
|
750
|
-
|
893
|
+
```ruby
|
894
|
+
# Subclass Person with and redefine the :age type.
|
895
|
+
Adult = Person[age?: Types::Integer[18..]]
|
896
|
+
```
|
897
|
+
|
898
|
+
These classes can be instantiated normally, and expose `#valid?` and `#error`
|
751
899
|
|
752
900
|
```ruby
|
753
|
-
|
754
|
-
|
901
|
+
person = Person.new(name: 'Joe')
|
902
|
+
person.name # 'Joe'
|
903
|
+
person.valid? # false
|
904
|
+
person.errors[:age] # 'must be an integer'
|
905
|
+
```
|
755
906
|
|
756
|
-
|
907
|
+
#### `#with`
|
908
|
+
|
909
|
+
Note that these instances cannot be mutated (there's no attribute setters), but they can be copied with partial attributes with `#with`
|
910
|
+
|
911
|
+
```ruby
|
912
|
+
another_person = person.with(age: 20)
|
757
913
|
```
|
758
914
|
|
915
|
+
#### `.attribute` syntax
|
759
916
|
|
760
|
-
|
917
|
+
This syntax allows defining struct classes with typed attributes, including nested structs.
|
761
918
|
|
762
919
|
```ruby
|
763
|
-
|
764
|
-
|
765
|
-
|
766
|
-
|
920
|
+
class Person < Types::Data
|
921
|
+
attribute :name, Types::String.present
|
922
|
+
attribute :age, Types::Integer
|
923
|
+
end
|
767
924
|
```
|
768
925
|
|
769
|
-
|
926
|
+
It supports nested attributes:
|
770
927
|
|
771
928
|
```ruby
|
772
|
-
|
773
|
-
|
774
|
-
|
929
|
+
class Person < Types::Data
|
930
|
+
attribute :friend do
|
931
|
+
attribute :name, String
|
932
|
+
end
|
933
|
+
end
|
934
|
+
|
935
|
+
person = Person.new(friend: { name: 'John' })
|
936
|
+
person.friend_count # 1
|
775
937
|
```
|
776
938
|
|
777
|
-
|
939
|
+
Or arrays of nested attributes:
|
778
940
|
|
779
941
|
```ruby
|
780
|
-
|
942
|
+
class Person < Types::Data
|
943
|
+
attribute :friends, Types::Array do
|
944
|
+
atrribute :name, String
|
945
|
+
end
|
946
|
+
|
947
|
+
# Custom methods like any other class
|
948
|
+
def friend_count = friends.size
|
949
|
+
end
|
781
950
|
|
782
|
-
|
783
|
-
|
784
|
-
|
785
|
-
|
786
|
-
|
787
|
-
|
788
|
-
|
789
|
-
|
790
|
-
|
791
|
-
FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD)
|
951
|
+
person = Person.new(friends: [{ name: 'John' }])
|
952
|
+
```
|
953
|
+
|
954
|
+
Or use struct classes defined separately:
|
955
|
+
|
956
|
+
```ruby
|
957
|
+
class Company < Types::Data
|
958
|
+
attribute :name, String
|
792
959
|
end
|
793
960
|
|
794
|
-
|
795
|
-
|
796
|
-
|
961
|
+
class Person < Types::Data
|
962
|
+
# Single nested struct
|
963
|
+
attribute :company, Company
|
964
|
+
|
965
|
+
# Array of nested structs
|
966
|
+
attribute :companies, Types::Array[Company]
|
967
|
+
end
|
968
|
+
```
|
969
|
+
|
970
|
+
Arrays and other types support composition and helpers. Ex. `#default`.
|
971
|
+
|
972
|
+
```ruby
|
973
|
+
attribute :companies, Types::Array[Company].default([].freeze)
|
974
|
+
```
|
975
|
+
|
976
|
+
Passing a named struct class AND a block will subclass the struct and extend it with new attributes:
|
977
|
+
|
978
|
+
```ruby
|
979
|
+
attribute :company, Company do
|
980
|
+
attribute :address, String
|
981
|
+
end
|
982
|
+
```
|
983
|
+
|
984
|
+
The same works with arrays:
|
985
|
+
|
986
|
+
```ruby
|
987
|
+
attribute :companies, Types::Array[Company] do
|
988
|
+
attribute :address, String
|
989
|
+
end
|
990
|
+
```
|
991
|
+
|
992
|
+
Note that this does NOT work with union'd or piped structs.
|
993
|
+
|
994
|
+
```ruby
|
995
|
+
attribute :company, Company | Person do
|
996
|
+
```
|
997
|
+
|
998
|
+
#### Optional Attributes
|
999
|
+
Using `attribute?` allows for optional attributes. If the attribute is not present, these attribute values will be `nil`
|
1000
|
+
|
1001
|
+
```ruby
|
1002
|
+
attribute? :company, Company
|
1003
|
+
```
|
1004
|
+
|
1005
|
+
#### Inheritance
|
1006
|
+
Data structs can inherit from other structs. This is useful for defining a base struct with common attributes.
|
1007
|
+
|
1008
|
+
```ruby
|
1009
|
+
class BasePerson < Types::Data
|
1010
|
+
attribute :name, String
|
1011
|
+
end
|
1012
|
+
|
1013
|
+
class Person < BasePerson
|
1014
|
+
attribute :age, Integer
|
1015
|
+
end
|
1016
|
+
```
|
1017
|
+
|
1018
|
+
#### Equality with `#==`
|
1019
|
+
|
1020
|
+
`#==` is implemented to compare attributes, recursively.
|
1021
|
+
|
1022
|
+
```ruby
|
1023
|
+
person1 = Person.new(name: 'Joe', age: 20)
|
1024
|
+
person2 = Person.new(name: 'Joe', age: 20)
|
1025
|
+
person1 == person2 # true
|
1026
|
+
```
|
1027
|
+
|
1028
|
+
#### Struct composition
|
1029
|
+
|
1030
|
+
`Types::Data` supports all the composition operators and helpers.
|
1031
|
+
|
1032
|
+
Note however that, once you wrap a struct in a composition, you can't instantiate it with `.new` anymore (but you can still use `#parse` or `#resolve` like any other Plumb type).
|
1033
|
+
|
1034
|
+
```ruby
|
1035
|
+
Person = Types::Data[name: String]
|
1036
|
+
Animal = Types::Data[species: String]
|
1037
|
+
# Compose with |
|
1038
|
+
Being = Person | Animal
|
1039
|
+
Being.parse(name: 'Joe') # <Person [valid] name: 'Joe'>
|
1040
|
+
|
1041
|
+
# Compose with other types
|
1042
|
+
Beings = Types::Array[Person | Animal]
|
1043
|
+
|
1044
|
+
# Default
|
1045
|
+
Payload = Types::Hash[
|
1046
|
+
being: Being.default(Person.new(name: 'Joe Bloggs'))
|
1047
|
+
]
|
797
1048
|
```
|
798
1049
|
|
1050
|
+
#### Recursive struct definitions
|
1051
|
+
|
1052
|
+
You can use `#defer`. See [recursive types](#recursive-types).
|
1053
|
+
|
1054
|
+
```ruby
|
1055
|
+
Person = Types::Data[
|
1056
|
+
name: String,
|
1057
|
+
friend?: Types::Any.defer { Person }
|
1058
|
+
]
|
1059
|
+
|
1060
|
+
person = Person.new(name: 'Joe', friend: { name: 'Joan'})
|
1061
|
+
person.friend.name # 'joan'
|
1062
|
+
person.friend.friend # nil
|
1063
|
+
```
|
1064
|
+
|
1065
|
+
|
1066
|
+
|
1067
|
+
### Plumb::Schema
|
1068
|
+
|
1069
|
+
TODO
|
1070
|
+
|
1071
|
+
### Plumb::Pipeline
|
799
1072
|
|
1073
|
+
TODO
|
800
1074
|
|
801
1075
|
### Recursive types
|
802
1076
|
|
@@ -831,19 +1105,48 @@ LinkedList = Types::Hash[
|
|
831
1105
|
|
832
1106
|
|
833
1107
|
|
834
|
-
###
|
1108
|
+
### Custom types
|
835
1109
|
|
836
|
-
|
1110
|
+
Every Plumb type exposes the following one-method interface:
|
837
1111
|
|
838
|
-
|
1112
|
+
```
|
1113
|
+
#call(Result::Valid) => Result::Valid | Result::Invalid
|
1114
|
+
```
|
1115
|
+
|
1116
|
+
As long as an object implements this interface, it can be composed into Plumb workflows.
|
839
1117
|
|
840
|
-
|
1118
|
+
The `Result::Valid` class has helper methods `#valid(value) => Result::Valid` and `#invalid(errors:) => Result::Invalid` to facilitate returning valid or invalid values from your own steps.
|
1119
|
+
|
1120
|
+
#### Compose procs or lambdas directly
|
1121
|
+
|
1122
|
+
Piping any `#call` object onto Plumb types will wrap your object in a `Plumb::Step` with all methods necessary for further composition.
|
841
1123
|
|
842
1124
|
```ruby
|
843
1125
|
Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
|
844
1126
|
```
|
845
1127
|
|
846
|
-
|
1128
|
+
#### Wrap a `#call` object in `Plumb::Step` explicitely
|
1129
|
+
|
1130
|
+
You can also wrap a proc in `Plumb::Step` explicitly.
|
1131
|
+
|
1132
|
+
```ruby
|
1133
|
+
Greeting = Plumb::Step.new do |result|
|
1134
|
+
result.valid("Hello #{result.value}")
|
1135
|
+
end
|
1136
|
+
```
|
1137
|
+
|
1138
|
+
Note that this example is not prefixed by `Types::String`, so it doesn't first validate that the input is indeed a string.
|
1139
|
+
|
1140
|
+
However, this means that `Greeting` is a `Plumb::Step` which comes with all the Plumb methods and policies.
|
1141
|
+
|
1142
|
+
```ruby
|
1143
|
+
# Greeting responds to #>>, #|, #default, #transform, etc etc
|
1144
|
+
LoudGreeting = Greeting.default('no greeting').invoke(:upcase)
|
1145
|
+
```
|
1146
|
+
|
1147
|
+
#### A custom `#call` class
|
1148
|
+
|
1149
|
+
Or write a custom class that responds to `#call(Result::Valid) => Result::Valid | Result::Invalid`
|
847
1150
|
|
848
1151
|
```ruby
|
849
1152
|
class Greeting
|
@@ -851,6 +1154,9 @@ class Greeting
|
|
851
1154
|
@gr = gr
|
852
1155
|
end
|
853
1156
|
|
1157
|
+
# The Plumb Step interface
|
1158
|
+
# @param result [Plumb::Result::Valid]
|
1159
|
+
# @return [Plumb::Result::Valid, Plumb::Result::Invalid]
|
854
1160
|
def call(result)
|
855
1161
|
result.valid("#{gr} #{result.value}")
|
856
1162
|
end
|
@@ -859,11 +1165,176 @@ end
|
|
859
1165
|
MyType = Types::String >> Greeting.new('Hola')
|
860
1166
|
```
|
861
1167
|
|
862
|
-
|
1168
|
+
This is useful when you want to parameterize your custom steps, for example by initialising them with arguments like the example above.
|
1169
|
+
|
1170
|
+
#### Include `Plumb::Composable` to make instance of a class full "steps"
|
1171
|
+
|
1172
|
+
The class above will be wrapped by `Plumb::Step` when piped into other steps, but it doesn't support Plumb methods on its own.
|
863
1173
|
|
1174
|
+
Including `Plumb::Composable` makes it support all Plumb methods directly.
|
1175
|
+
|
1176
|
+
```ruby
|
1177
|
+
class Greeting
|
1178
|
+
# This module mixes in Plumb methods such as #>>, #|, #default, #[],
|
1179
|
+
# #transform, #policy, etc etc
|
1180
|
+
include Plumb::Composable
|
1181
|
+
|
1182
|
+
def initialize(gr = 'Hello')
|
1183
|
+
@gr = gr
|
1184
|
+
end
|
1185
|
+
|
1186
|
+
# The Step interface
|
1187
|
+
def call(result)
|
1188
|
+
result.valid("#{gr} #{result.value}")
|
1189
|
+
end
|
1190
|
+
|
1191
|
+
# This is optional, but it allows you to control your object's #inspect
|
1192
|
+
private def _inspect = "Greeting[#{@gr}]"
|
1193
|
+
end
|
1194
|
+
```
|
1195
|
+
|
1196
|
+
Now you can use your class as a composition starting point directly.
|
1197
|
+
|
1198
|
+
```ruby
|
1199
|
+
LoudGreeting = Greeting.new('Hola').default('no greeting').invoke(:upcase)
|
1200
|
+
```
|
1201
|
+
|
1202
|
+
#### Extend a class with `Plumb::Composable` to make the class itself a composable step.
|
1203
|
+
|
1204
|
+
```ruby
|
1205
|
+
class User
|
1206
|
+
extend Composable
|
1207
|
+
|
1208
|
+
def self.class(result)
|
1209
|
+
# do something here. Perhaps returning a Result with an instance of this class
|
1210
|
+
return result.valid(new)
|
1211
|
+
end
|
1212
|
+
end
|
1213
|
+
```
|
1214
|
+
|
1215
|
+
This is how [Plumb::Types::Data](#typesdata) is implemented.
|
1216
|
+
|
1217
|
+
### Custom policies
|
1218
|
+
|
1219
|
+
`Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
|
1220
|
+
|
1221
|
+
This example defines a `:default_if_nil` policy that returns a default if the value is `nil`.
|
1222
|
+
|
1223
|
+
```ruby
|
1224
|
+
Plumb.policy :default_if_nil do |type, default_value|
|
1225
|
+
type | (Types::Nil >> Types::Static[default_value])
|
1226
|
+
end
|
1227
|
+
```
|
1228
|
+
|
1229
|
+
It can be used for any of your own types.
|
1230
|
+
|
1231
|
+
```ruby
|
1232
|
+
StringWithDefault = Types::String.policy(default_if_nil: 'nothing here')
|
1233
|
+
StringWithDefault.parse('hello') # 'hello'
|
1234
|
+
StringWithDefault.parse(nil) # 'nothing here'
|
1235
|
+
```
|
1236
|
+
|
1237
|
+
The `#policy` helper supports applying multiply policies.
|
1238
|
+
|
1239
|
+
```ruby
|
1240
|
+
Types::String.policy(default_if_nil: 'nothing here', size: (10..20))
|
1241
|
+
```
|
1242
|
+
|
1243
|
+
#### Policies as helper methods
|
1244
|
+
|
1245
|
+
Use the `helper: true` option to register the policy as a method you can call on types directly.
|
1246
|
+
|
1247
|
+
```ruby
|
1248
|
+
Plumb.policy :default_if_nil, helper: true do |type, default_value|
|
1249
|
+
type | (Types::Nil >> Types::Static[default_value])
|
1250
|
+
end
|
1251
|
+
|
1252
|
+
# Now use #default_if_nil directly
|
1253
|
+
StringWithDefault = Types::String.default_if_nil('nothing here')
|
1254
|
+
```
|
1255
|
+
|
1256
|
+
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!).
|
1257
|
+
|
1258
|
+
This other example adds a boolean to type metadata.
|
1259
|
+
|
1260
|
+
```ruby
|
1261
|
+
Plumb.policy :admin, helper: true do |type|
|
1262
|
+
type.metadata(admin: true)
|
1263
|
+
end
|
1264
|
+
|
1265
|
+
# Usage: annotate fields in a schema
|
1266
|
+
AccountName = Types::String.admin
|
1267
|
+
AccountName.metadata # => { type: String, admin: true }
|
1268
|
+
```
|
1269
|
+
|
1270
|
+
#### Type-specific policies
|
1271
|
+
|
1272
|
+
You can use the `for_type:` option to define policies that only apply to steps that output certain types. This example is only applicable for types that return `Integer` values.
|
1273
|
+
|
1274
|
+
```ruby
|
1275
|
+
Plumb.policy :multiply_by, for_type: Integer, helper: true do |type, factor|
|
1276
|
+
type.invoke(:*, factor)
|
1277
|
+
end
|
1278
|
+
|
1279
|
+
Doubled = Types::Integer.multiply_by(2)
|
1280
|
+
Doubled.parse(2) # 4
|
1281
|
+
|
1282
|
+
# Trying to apply this policy to a non Integer will raise an exception
|
1283
|
+
DoubledString = Types::String.multiply_by(2) # raises error
|
1284
|
+
```
|
1285
|
+
|
1286
|
+
#### Interface-specific policies
|
1287
|
+
|
1288
|
+
`for_type`also supports a Symbol for a method name, so that the policy can be applied to any types that support that method.
|
1289
|
+
|
1290
|
+
This example allows the `multiply_by` policy to work with any type that can be multiplied (by supporting the `:*` method).
|
1291
|
+
|
1292
|
+
```ruby
|
1293
|
+
Plumb.policy :multiply_by, for_type: :*, helper: true do |type, factor|
|
1294
|
+
type.invoke(:*, factor)
|
1295
|
+
end
|
1296
|
+
|
1297
|
+
# Now it works with anything that can be multiplied.
|
1298
|
+
DoubledNumeric = Types::Numeric.multiply_by(2)
|
1299
|
+
DoubledMoney = Types::Any[Money].multiply_by(2)
|
1300
|
+
```
|
1301
|
+
|
1302
|
+
#### Self-contained policy modules
|
1303
|
+
|
1304
|
+
You can register a module, class or object 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.
|
1305
|
+
|
1306
|
+
```ruby
|
1307
|
+
module SplitPolicy
|
1308
|
+
DEFAULT_SEPARATOR = /\s*,\s*/
|
1309
|
+
|
1310
|
+
def self.call(type, separator = DEFAULT_SEPARATOR)
|
1311
|
+
type.transform(Array) { |v| v.split(separator) }
|
1312
|
+
end
|
1313
|
+
|
1314
|
+
def self.for_type = ::String
|
1315
|
+
def self.helper = false
|
1316
|
+
end
|
1317
|
+
|
1318
|
+
Plumb.policy :split, SplitPolicy
|
1319
|
+
```
|
864
1320
|
|
865
1321
|
### JSON Schema
|
866
1322
|
|
1323
|
+
Plumb ships with a JSON schema visitor that compiles a type composition into a JSON Schema Hash. All Plumb types support a `#to_json_schema` method.
|
1324
|
+
|
1325
|
+
```ruby
|
1326
|
+
Payload = Types::Hash[name: String]
|
1327
|
+
Payload.to_json_schema(root: true)
|
1328
|
+
# {
|
1329
|
+
# "$schema"=>"https://json-schema.org/draft-08/schema#",
|
1330
|
+
# "type"=>"object",
|
1331
|
+
# "properties"=>{"name"=>{"type"=>"string"}},
|
1332
|
+
# "required"=>["name"]
|
1333
|
+
# }
|
1334
|
+
```
|
1335
|
+
|
1336
|
+
The visitor can be used directly, too.
|
1337
|
+
|
867
1338
|
```ruby
|
868
1339
|
User = Types::Hash[
|
869
1340
|
name: Types::String,
|
@@ -883,7 +1354,43 @@ json_schema = Plumb::JSONSchemaVisitor.call(User)
|
|
883
1354
|
}
|
884
1355
|
```
|
885
1356
|
|
1357
|
+
The built-in JSON Schema generator handles most standard types and compositions. You can add or override handlers on a per-type basis with:
|
1358
|
+
|
1359
|
+
```ruby
|
1360
|
+
Plumb::JSONSchemaVisitor.on(:not) do |node, props|
|
1361
|
+
props.merge('not' => visit(node.step))
|
1362
|
+
end
|
1363
|
+
|
1364
|
+
# Example
|
1365
|
+
type = Types::Decimal.not
|
1366
|
+
schema = Plumb::JSONSchemaVisitor.visit(type) # { 'not' => { 'type' => 'number' } }
|
1367
|
+
```
|
1368
|
+
|
1369
|
+
You can also register custom classes or types that are wrapped by Plumb steps.
|
1370
|
+
|
1371
|
+
```ruby
|
1372
|
+
module Types
|
1373
|
+
DateTime = Any[::DateTime]
|
1374
|
+
end
|
1375
|
+
|
1376
|
+
Plumb::JSONSchemaVisitor.on(::DateTime) do |node, props|
|
1377
|
+
props.merge('type' => 'string', 'format' => 'date-time')
|
1378
|
+
end
|
1379
|
+
|
1380
|
+
Types::DateTime.to_json_schema
|
1381
|
+
# {"type"=>"string", "format"=>"date-time"}
|
1382
|
+
```
|
1383
|
+
|
1384
|
+
|
1385
|
+
|
1386
|
+
## TODO:
|
886
1387
|
|
1388
|
+
- [ ] benchmarks and performace. Compare with `Parametric`, `ActiveModel::Attributes`, `ActionController::StrongParameters`
|
1389
|
+
- [ ] flesh out `Plumb::Schema`
|
1390
|
+
- [x] `Plumb::Struct`
|
1391
|
+
- [ ] flesh out and document `Plumb::Pipeline`
|
1392
|
+
- [ ] document custom visitors
|
1393
|
+
- [ ] Improve errors, support I18n ?
|
887
1394
|
|
888
1395
|
## Development
|
889
1396
|
|