plumb 0.0.3 → 0.0.5
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 +609 -57
- data/bench/compare_parametric_schema.rb +102 -0
- data/bench/compare_parametric_struct.rb +68 -0
- data/bench/parametric_schema.rb +229 -0
- data/bench/plumb_hash.rb +109 -0
- data/examples/concurrent_downloads.rb +3 -3
- data/examples/env_config.rb +2 -2
- data/examples/event_registry.rb +127 -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 +268 -0
- data/lib/plumb/build.rb +4 -3
- data/lib/plumb/composable.rb +381 -0
- data/lib/plumb/decorator.rb +57 -0
- data/lib/plumb/deferred.rb +1 -1
- data/lib/plumb/hash_class.rb +19 -8
- data/lib/plumb/hash_map.rb +8 -6
- data/lib/plumb/interface_class.rb +6 -2
- data/lib/plumb/json_schema_visitor.rb +59 -32
- data/lib/plumb/match_class.rb +5 -4
- data/lib/plumb/metadata.rb +5 -1
- data/lib/plumb/metadata_visitor.rb +13 -42
- data/lib/plumb/not.rb +4 -3
- data/lib/plumb/or.rb +10 -4
- data/lib/plumb/pipeline.rb +27 -7
- data/lib/plumb/policy.rb +10 -3
- data/lib/plumb/schema.rb +11 -10
- 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 +11 -11
- 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 +30 -1
- data/lib/plumb/value_class.rb +4 -3
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +6 -0
- data/lib/plumb.rb +11 -5
- metadata +10 -3
- data/lib/plumb/steppable.rb +0 -229
data/README.md
CHANGED
@@ -10,13 +10,19 @@ If you're after raw performance and versatility I strongly recommend you use the
|
|
10
10
|
|
11
11
|
For a description of the core architecture you can read [this article](https://ismaelcelis.com/posts/composable-pipelines-in-ruby/).
|
12
12
|
|
13
|
+
Some use cases in the [examples directory](https://github.com/ismasan/plumb/tree/main/examples)
|
14
|
+
|
13
15
|
## Installation
|
14
16
|
|
15
|
-
|
17
|
+
Install in your environment with `gem install plumb`, or in your `Gemfile` with
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
gem 'plumb'
|
21
|
+
```
|
16
22
|
|
17
23
|
## Usage
|
18
24
|
|
19
|
-
### Include base types
|
25
|
+
### Include base types.
|
20
26
|
|
21
27
|
Include base types in your own namespace:
|
22
28
|
|
@@ -39,6 +45,8 @@ result.valid? # false
|
|
39
45
|
result.errors # ""
|
40
46
|
```
|
41
47
|
|
48
|
+
Note that this is not mandatory. You can also work with the `Plumb::Types` module directly, ex. `Plumb::Types::String`
|
49
|
+
|
42
50
|
### Specialize your types with `#[]`
|
43
51
|
|
44
52
|
Use `#[]` to make your types match a class.
|
@@ -47,12 +55,12 @@ Use `#[]` to make your types match a class.
|
|
47
55
|
module Types
|
48
56
|
include Plumb::Types
|
49
57
|
|
50
|
-
String =
|
51
|
-
Integer =
|
58
|
+
String = Any[::String]
|
59
|
+
Integer = Any[::Integer]
|
52
60
|
end
|
53
61
|
|
54
62
|
Types::String.parse("hello") # => "hello"
|
55
|
-
Types::String.parse(10) # raises "Must be a String" (Plumb::
|
63
|
+
Types::String.parse(10) # raises "Must be a String" (Plumb::ParseError)
|
56
64
|
```
|
57
65
|
|
58
66
|
Plumb ships with basic types already defined, such as `Types::String` and `Types::Integer`. See the full list below.
|
@@ -69,7 +77,7 @@ Email.parse('hello@server.com') # 'hello@server.com'
|
|
69
77
|
# Or a Range
|
70
78
|
AdultAge = Types::Integer[18..]
|
71
79
|
AdultAge.parse(20) # 20
|
72
|
-
AdultAge.parse(17) # raises "Must be within 18.."" (Plumb::
|
80
|
+
AdultAge.parse(17) # raises "Must be within 18.."" (Plumb::ParseError)
|
73
81
|
|
74
82
|
# Or literal values
|
75
83
|
Twenty = Types::Integer[20]
|
@@ -107,7 +115,7 @@ result.errors # 'must be an Integer'
|
|
107
115
|
|
108
116
|
```ruby
|
109
117
|
Types::Integer.parse(10) # 10
|
110
|
-
Types::Integer.parse('10') # raises Plumb::
|
118
|
+
Types::Integer.parse('10') # raises Plumb::ParseError
|
111
119
|
```
|
112
120
|
|
113
121
|
|
@@ -127,13 +135,13 @@ joe = User.parse({ name: 'Joe', email: 'joe@email.com', age: 20}) # returns vali
|
|
127
135
|
Users.parse([joe]) # returns valid array of user hashes
|
128
136
|
```
|
129
137
|
|
130
|
-
More about [Types::Array](#typeshash) and [Types::Array](#typesarray). There's also tuples
|
138
|
+
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.
|
131
139
|
|
132
|
-
|
140
|
+
### Type composition
|
133
141
|
|
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
|
142
|
+
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
143
|
|
136
|
-
|
144
|
+
#### Composing types with `#>>` ("And")
|
137
145
|
|
138
146
|
```ruby
|
139
147
|
Email = Types::String[/@/]
|
@@ -143,13 +151,19 @@ Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") }
|
|
143
151
|
Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
144
152
|
```
|
145
153
|
|
146
|
-
|
154
|
+
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.
|
155
|
+
|
156
|
+
In other words, `A >> B` means "if A succeeds, pass its result to B. Otherwise return A's failed result."
|
157
|
+
|
158
|
+
#### Disjunction with `#|` ("Or")
|
159
|
+
|
160
|
+
`A | B` means "if A returns a valid result, return that. Otherwise try B with the original input."
|
147
161
|
|
148
162
|
```ruby
|
149
163
|
StringOrInt = Types::String | Types::Integer
|
150
164
|
StringOrInt.parse('hello') # "hello"
|
151
165
|
StringOrInt.parse(10) # 10
|
152
|
-
StringOrInt.parse({}) # raises Plumb::
|
166
|
+
StringOrInt.parse({}) # raises Plumb::ParseError
|
153
167
|
```
|
154
168
|
|
155
169
|
Custom default value logic for non-emails
|
@@ -160,9 +174,13 @@ EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
|
160
174
|
EmailOrDefault.parse('nope') # "no email"
|
161
175
|
```
|
162
176
|
|
163
|
-
|
177
|
+
#### Composing with `#>>` and `#|`
|
164
178
|
|
165
|
-
|
179
|
+
Combine `#>>` and `#|` to compose branching workflows, or types that accept and output several possible data types.
|
180
|
+
|
181
|
+
`((A >> B) | C | D) >> E)`
|
182
|
+
|
183
|
+
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.
|
166
184
|
|
167
185
|
```ruby
|
168
186
|
require 'money'
|
@@ -170,12 +188,22 @@ require 'money'
|
|
170
188
|
module Types
|
171
189
|
include Plumb::Types
|
172
190
|
|
191
|
+
# Match any Money instance
|
173
192
|
Money = Any[::Money]
|
193
|
+
|
194
|
+
# Transform Integers into Money instances
|
174
195
|
IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') }
|
196
|
+
|
197
|
+
# Transform integer-looking Strings into Integers
|
175
198
|
StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i)
|
199
|
+
|
200
|
+
# Validate that a Money instance is USD
|
176
201
|
USD = Money.check { |amount| amount.currency.code == 'UDS' }
|
202
|
+
|
203
|
+
# Exchange a non-USD Money instance into USD
|
177
204
|
ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') }
|
178
205
|
|
206
|
+
# Compose a pipeline that accepts Strings, Integers or Money and returns USD money.
|
179
207
|
FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD)
|
180
208
|
end
|
181
209
|
|
@@ -184,9 +212,9 @@ FlexibleUSD.parse(1000) # Money(USD 10.00)
|
|
184
212
|
FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
|
185
213
|
```
|
186
214
|
|
187
|
-
You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/examples)
|
215
|
+
You can see more use cases in [the examples directory](https://github.com/ismasan/plumb/tree/main/examples)
|
188
216
|
|
189
|
-
|
217
|
+
### Built-in types
|
190
218
|
|
191
219
|
* `Types::Value`
|
192
220
|
* `Types::Array`
|
@@ -196,7 +224,6 @@ You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/
|
|
196
224
|
* `Types::Interface`
|
197
225
|
* `Types::False`
|
198
226
|
* `Types::Tuple`
|
199
|
-
* `Types::Split`
|
200
227
|
* `Types::Any`
|
201
228
|
* `Types::Static`
|
202
229
|
* `Types::Undefined`
|
@@ -205,6 +232,9 @@ You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/
|
|
205
232
|
* `Types::Numeric`
|
206
233
|
* `Types::String`
|
207
234
|
* `Types::Hash`
|
235
|
+
* `Types::UUID::V4`
|
236
|
+
* `Types::Email`
|
237
|
+
* `Types::Date`
|
208
238
|
* `Types::Lax::Integer`
|
209
239
|
* `Types::Lax::String`
|
210
240
|
* `Types::Lax::Symbol`
|
@@ -212,14 +242,15 @@ You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/
|
|
212
242
|
* `Types::Forms::Nil`
|
213
243
|
* `Types::Forms::True`
|
214
244
|
* `Types::Forms::False`
|
245
|
+
* `Types::Forms::Date`
|
215
246
|
|
216
|
-
|
247
|
+
TODO: datetime, others.
|
217
248
|
|
218
249
|
### Policies
|
219
250
|
|
220
|
-
Policies are
|
251
|
+
Policies are helpers that encapsulate common compositions. Plumb ships with some handy ones, listed below, and you can also define your own.
|
221
252
|
|
222
|
-
|
253
|
+
#### `#present`
|
223
254
|
|
224
255
|
Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`)
|
225
256
|
|
@@ -228,7 +259,7 @@ Types::String.present.resolve('') # Failure with errors
|
|
228
259
|
Types::Array[Types::String].resolve([]) # Failure with errors
|
229
260
|
```
|
230
261
|
|
231
|
-
|
262
|
+
#### `#nullable`
|
232
263
|
|
233
264
|
Allow `nil` values.
|
234
265
|
|
@@ -236,7 +267,7 @@ Allow `nil` values.
|
|
236
267
|
nullable_str = Types::String.nullable
|
237
268
|
nullable_srt.parse(nil) # nil
|
238
269
|
nullable_str.parse('hello') # 'hello'
|
239
|
-
nullable_str.parse(10) #
|
270
|
+
nullable_str.parse(10) # ParseError
|
240
271
|
```
|
241
272
|
|
242
273
|
Note that this just encapsulates the following composition:
|
@@ -245,7 +276,7 @@ Note that this just encapsulates the following composition:
|
|
245
276
|
nullable_str = Types::String | Types::Nil
|
246
277
|
```
|
247
278
|
|
248
|
-
|
279
|
+
#### `#not`
|
249
280
|
|
250
281
|
Negates a type.
|
251
282
|
```ruby
|
@@ -255,7 +286,7 @@ NotEmail.parse('hello') # "hello"
|
|
255
286
|
NotEmail.parse('hello@server.com') # error
|
256
287
|
```
|
257
288
|
|
258
|
-
|
289
|
+
#### `#options`
|
259
290
|
|
260
291
|
Sets allowed options for value.
|
261
292
|
|
@@ -273,7 +304,7 @@ type.resolve(['a', 'a', 'b']) # Valid
|
|
273
304
|
type.resolve(['a', 'x', 'b']) # Failure
|
274
305
|
```
|
275
306
|
|
276
|
-
|
307
|
+
#### `#transform`
|
277
308
|
|
278
309
|
Transform value. Requires specifying the resulting type of the value after transformation.
|
279
310
|
|
@@ -285,7 +316,7 @@ StringToInt = Types::String.transform(Integer, &:to_i)
|
|
285
316
|
StringToInteger.parse('10') # => 10
|
286
317
|
```
|
287
318
|
|
288
|
-
|
319
|
+
#### `#invoke`
|
289
320
|
|
290
321
|
`#invoke` builds a Step that will invoke one or more methods on the value.
|
291
322
|
|
@@ -311,7 +342,7 @@ UpcaseToSym = Types::String.invoke(%i[downcase to_sym])
|
|
311
342
|
UpcaseToSym.parse('FOO_BAR') # :foo_bar
|
312
343
|
```
|
313
344
|
|
314
|
-
|
345
|
+
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).
|
315
346
|
|
316
347
|
Also, there's no definition-time checks that the method names are actually supported by the input values.
|
317
348
|
|
@@ -322,7 +353,7 @@ type.parse([1, 2]) # raises NoMethodError because Array doesn't respond to #stri
|
|
322
353
|
|
323
354
|
Use with caution.
|
324
355
|
|
325
|
-
|
356
|
+
#### `#default`
|
326
357
|
|
327
358
|
Default value when no value given (ie. when key is missing in Hash payloads. See `Types::Hash` below).
|
328
359
|
|
@@ -356,7 +387,7 @@ Same if you want to apply a default to several cases.
|
|
356
387
|
str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
|
357
388
|
```
|
358
389
|
|
359
|
-
|
390
|
+
#### `#build`
|
360
391
|
|
361
392
|
Build a custom object or class.
|
362
393
|
|
@@ -389,7 +420,7 @@ Note that this case is identical to `#transform` with a block.
|
|
389
420
|
StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
|
390
421
|
```
|
391
422
|
|
392
|
-
|
423
|
+
#### `#check`
|
393
424
|
|
394
425
|
Pass the value through an arbitrary validation
|
395
426
|
|
@@ -399,9 +430,7 @@ type.parse('Role: Manager') # 'Role: Manager'
|
|
399
430
|
type.parse('Manager') # fails
|
400
431
|
```
|
401
432
|
|
402
|
-
|
403
|
-
|
404
|
-
### `#value`
|
433
|
+
#### `#value`
|
405
434
|
|
406
435
|
Constrain a type to a specific value. Compares with `#==`
|
407
436
|
|
@@ -418,19 +447,21 @@ All scalar types support this:
|
|
418
447
|
ten = Types::Integer.value(10)
|
419
448
|
```
|
420
449
|
|
421
|
-
|
450
|
+
#### `#metadata`
|
422
451
|
|
423
452
|
Add metadata to a type
|
424
453
|
|
425
454
|
```ruby
|
426
|
-
|
455
|
+
# A new type with metadata
|
456
|
+
type = Types::String.metadata(description: 'A long text')
|
457
|
+
# Read a type's metadata
|
427
458
|
type.metadata[:description] # 'A long text'
|
428
459
|
```
|
429
460
|
|
430
461
|
`#metadata` combines keys from type compositions.
|
431
462
|
|
432
463
|
```ruby
|
433
|
-
type = Types::String.
|
464
|
+
type = Types::String.metadata(description: 'A long text') >> Types::String.match(/@/).metadata(note: 'An email address')
|
434
465
|
type.metadata[:description] # 'A long text'
|
435
466
|
type.metadata[:note] # 'An email address'
|
436
467
|
```
|
@@ -497,7 +528,52 @@ CSVLine = Types::String.split(/\s*;\s*/)
|
|
497
528
|
CSVLine.parse('a;b;c') # => ['a', 'b', 'c']
|
498
529
|
```
|
499
530
|
|
531
|
+
#### `:rescue`
|
500
532
|
|
533
|
+
Wraps a step's execution, rescues a specific exception and returns an invalid result.
|
534
|
+
|
535
|
+
Useful for turning a 3rd party library's exception into an invalid result that plays well with Plumb's type compositions.
|
536
|
+
|
537
|
+
Example: this is how `Types::Forms::Date` uses the `:rescue` policy to parse strings with `Date.parse` and turn `Date::Error` exceptions into Plumb errors.
|
538
|
+
|
539
|
+
```ruby
|
540
|
+
# Accept a string that can be parsed into a Date
|
541
|
+
# via Date.parse
|
542
|
+
# If Date.parse raises a Date::Error, return a Result::Invalid with
|
543
|
+
# the exception's message as error message.
|
544
|
+
type = Types::String
|
545
|
+
.build(::Date, :parse)
|
546
|
+
.policy(:rescue, ::Date::Error)
|
547
|
+
|
548
|
+
type.resolve('2024-02-02') # => Result::Valid with Date object
|
549
|
+
type.resolve('2024-') # => Result::Invalid with error message
|
550
|
+
```
|
551
|
+
|
552
|
+
### `Types::Interface`
|
553
|
+
|
554
|
+
Use this for objects that must respond to one or more methods.
|
555
|
+
|
556
|
+
```ruby
|
557
|
+
Iterable = Types::Interface[:each, :map]
|
558
|
+
Iterable.parse([1,2,3]) # => [1,2,3]
|
559
|
+
Iterable.parse(10) # => raises error
|
560
|
+
```
|
561
|
+
|
562
|
+
This can be useful combined with `case` statements, too:
|
563
|
+
|
564
|
+
```ruby
|
565
|
+
value = [1,2,3]
|
566
|
+
case value
|
567
|
+
when Iterable
|
568
|
+
# do something with array
|
569
|
+
when Stringable
|
570
|
+
# do something with string
|
571
|
+
when Readable
|
572
|
+
# do something with IO or similar
|
573
|
+
end
|
574
|
+
```
|
575
|
+
|
576
|
+
TODO: make this a bit more advanced. Check for method arity.
|
501
577
|
|
502
578
|
### `Types::Hash`
|
503
579
|
|
@@ -614,11 +690,9 @@ intersection = User & Employee # Hash[:name]
|
|
614
690
|
|
615
691
|
Use `#tagged_by` to resolve what definition to use based on the value of a common key.
|
616
692
|
|
617
|
-
Key used as index must be a `Types::Static`
|
618
|
-
|
619
693
|
```ruby
|
620
|
-
NameUpdatedEvent = Types::Hash[type:
|
621
|
-
AgeUpdatedEvent = Types::Hash[type:
|
694
|
+
NameUpdatedEvent = Types::Hash[type: 'name_updated', name: Types::String]
|
695
|
+
AgeUpdatedEvent = Types::Hash[type: 'age_updated', age: Types::Integer]
|
622
696
|
|
623
697
|
Events = Types::Hash.tagged_by(
|
624
698
|
:type,
|
@@ -664,7 +738,7 @@ InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
|
|
664
738
|
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
739
|
|
666
740
|
```ruby
|
667
|
-
User = Types::Hash[name: String, age: Integer]
|
741
|
+
User = Types::Hash[name: String, age: Integer].filtered
|
668
742
|
User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
|
669
743
|
User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
|
670
744
|
```
|
@@ -729,7 +803,7 @@ emails = Types::Array[/@/]
|
|
729
803
|
emails = Types::Array[Types::String[/@/]]
|
730
804
|
```
|
731
805
|
|
732
|
-
Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching
|
806
|
+
Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching against the regular expression.
|
733
807
|
|
734
808
|
#### Concurrent arrays
|
735
809
|
|
@@ -851,15 +925,363 @@ Str.parse(data).each do |row|
|
|
851
925
|
end
|
852
926
|
```
|
853
927
|
|
854
|
-
###
|
928
|
+
### Types::Data
|
855
929
|
|
856
|
-
|
930
|
+
`Types::Data` provides a superclass to define **inmutable** structs or value objects with typed / coercible attributes.
|
931
|
+
|
932
|
+
#### `[]` Syntax
|
933
|
+
|
934
|
+
The `[]` syntax is a short-hand for struct definition.
|
935
|
+
Like `Plumb::Types::Hash`, suffixing a key with `?` makes it optional.
|
936
|
+
|
937
|
+
```ruby
|
938
|
+
Person = Types::Data[name: String, age?: Integer]
|
939
|
+
person = Person.new(name: 'Jane')
|
940
|
+
```
|
941
|
+
|
942
|
+
This syntax creates subclasses too.
|
943
|
+
|
944
|
+
```ruby
|
945
|
+
# Subclass Person with and redefine the :age type.
|
946
|
+
Adult = Person[age?: Types::Integer[18..]]
|
947
|
+
```
|
948
|
+
|
949
|
+
These classes can be instantiated normally, and expose `#valid?` and `#error`
|
950
|
+
|
951
|
+
```ruby
|
952
|
+
person = Person.new(name: 'Joe')
|
953
|
+
person.name # 'Joe'
|
954
|
+
person.valid? # false
|
955
|
+
person.errors[:age] # 'must be an integer'
|
956
|
+
```
|
957
|
+
|
958
|
+
Data structs can also be defined from `Types::Hash` instances.
|
959
|
+
|
960
|
+
```ruby
|
961
|
+
PersonHash = Types::Hash[name: String, age?: Integer]
|
962
|
+
PersonStruct = Types::Data[PersonHash]
|
963
|
+
```
|
964
|
+
|
965
|
+
#### `#with`
|
966
|
+
|
967
|
+
Note that these instances cannot be mutated (there's no attribute setters), but they can be copied with partial attributes with `#with`
|
968
|
+
|
969
|
+
```ruby
|
970
|
+
another_person = person.with(age: 20)
|
971
|
+
```
|
972
|
+
|
973
|
+
#### `.attribute` syntax
|
974
|
+
|
975
|
+
This syntax allows defining struct classes with typed attributes, including nested structs.
|
976
|
+
|
977
|
+
```ruby
|
978
|
+
class Person < Types::Data
|
979
|
+
attribute :name, Types::String.present
|
980
|
+
attribute :age, Types::Integer
|
981
|
+
end
|
982
|
+
```
|
983
|
+
|
984
|
+
It supports nested attributes:
|
985
|
+
|
986
|
+
```ruby
|
987
|
+
class Person < Types::Data
|
988
|
+
attribute :friend do
|
989
|
+
attribute :name, String
|
990
|
+
end
|
991
|
+
end
|
992
|
+
|
993
|
+
person = Person.new(friend: { name: 'John' })
|
994
|
+
person.friend_count # 1
|
995
|
+
```
|
996
|
+
|
997
|
+
Or arrays of nested attributes:
|
998
|
+
|
999
|
+
```ruby
|
1000
|
+
class Person < Types::Data
|
1001
|
+
attribute :friends, Types::Array do
|
1002
|
+
atrribute :name, String
|
1003
|
+
end
|
1004
|
+
|
1005
|
+
# Custom methods like any other class
|
1006
|
+
def friend_count = friends.size
|
1007
|
+
end
|
1008
|
+
|
1009
|
+
person = Person.new(friends: [{ name: 'John' }])
|
1010
|
+
```
|
1011
|
+
|
1012
|
+
Or use struct classes defined separately:
|
1013
|
+
|
1014
|
+
```ruby
|
1015
|
+
class Company < Types::Data
|
1016
|
+
attribute :name, String
|
1017
|
+
end
|
1018
|
+
|
1019
|
+
class Person < Types::Data
|
1020
|
+
# Single nested struct
|
1021
|
+
attribute :company, Company
|
1022
|
+
|
1023
|
+
# Array of nested structs
|
1024
|
+
attribute :companies, Types::Array[Company]
|
1025
|
+
end
|
1026
|
+
```
|
1027
|
+
|
1028
|
+
Arrays and other types support composition and helpers. Ex. `#default`.
|
1029
|
+
|
1030
|
+
```ruby
|
1031
|
+
attribute :companies, Types::Array[Company].default([].freeze)
|
1032
|
+
```
|
1033
|
+
|
1034
|
+
Passing a named struct class AND a block will subclass the struct and extend it with new attributes:
|
1035
|
+
|
1036
|
+
```ruby
|
1037
|
+
attribute :company, Company do
|
1038
|
+
attribute :address, String
|
1039
|
+
end
|
1040
|
+
```
|
1041
|
+
|
1042
|
+
The same works with arrays:
|
1043
|
+
|
1044
|
+
```ruby
|
1045
|
+
attribute :companies, Types::Array[Company] do
|
1046
|
+
attribute :address, String
|
1047
|
+
end
|
1048
|
+
```
|
1049
|
+
|
1050
|
+
Note that this does NOT work with union'd or piped structs.
|
1051
|
+
|
1052
|
+
```ruby
|
1053
|
+
attribute :company, Company | Person do
|
1054
|
+
```
|
1055
|
+
|
1056
|
+
#### Optional Attributes
|
1057
|
+
Using `attribute?` allows for optional attributes. If the attribute is not present, these attribute values will be `nil`
|
1058
|
+
|
1059
|
+
```ruby
|
1060
|
+
attribute? :company, Company
|
1061
|
+
```
|
1062
|
+
|
1063
|
+
#### Inheritance
|
1064
|
+
Data structs can inherit from other structs. This is useful for defining a base struct with common attributes.
|
1065
|
+
|
1066
|
+
```ruby
|
1067
|
+
class BasePerson < Types::Data
|
1068
|
+
attribute :name, String
|
1069
|
+
end
|
1070
|
+
|
1071
|
+
class Person < BasePerson
|
1072
|
+
attribute :age, Integer
|
1073
|
+
end
|
1074
|
+
```
|
1075
|
+
|
1076
|
+
#### Equality with `#==`
|
1077
|
+
|
1078
|
+
`#==` is implemented to compare attributes, recursively.
|
1079
|
+
|
1080
|
+
```ruby
|
1081
|
+
person1 = Person.new(name: 'Joe', age: 20)
|
1082
|
+
person2 = Person.new(name: 'Joe', age: 20)
|
1083
|
+
person1 == person2 # true
|
1084
|
+
```
|
1085
|
+
|
1086
|
+
#### Struct composition
|
1087
|
+
|
1088
|
+
`Types::Data` supports all the composition operators and helpers.
|
1089
|
+
|
1090
|
+
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).
|
1091
|
+
|
1092
|
+
```ruby
|
1093
|
+
Person = Types::Data[name: String]
|
1094
|
+
Animal = Types::Data[species: String]
|
1095
|
+
# Compose with |
|
1096
|
+
Being = Person | Animal
|
1097
|
+
Being.parse(name: 'Joe') # <Person [valid] name: 'Joe'>
|
1098
|
+
|
1099
|
+
# Compose with other types
|
1100
|
+
Beings = Types::Array[Person | Animal]
|
1101
|
+
|
1102
|
+
# Default
|
1103
|
+
Payload = Types::Hash[
|
1104
|
+
being: Being.default(Person.new(name: 'Joe Bloggs'))
|
1105
|
+
]
|
1106
|
+
```
|
1107
|
+
|
1108
|
+
#### Recursive struct definitions
|
1109
|
+
|
1110
|
+
You can use `#defer`. See [recursive types](#recursive-types).
|
1111
|
+
|
1112
|
+
```ruby
|
1113
|
+
Person = Types::Data[
|
1114
|
+
name: String,
|
1115
|
+
friend?: Types::Any.defer { Person }
|
1116
|
+
]
|
1117
|
+
|
1118
|
+
person = Person.new(name: 'Joe', friend: { name: 'Joan'})
|
1119
|
+
person.friend.name # 'joan'
|
1120
|
+
person.friend.friend # nil
|
1121
|
+
```
|
857
1122
|
|
858
1123
|
### Plumb::Pipeline
|
859
1124
|
|
860
|
-
|
1125
|
+
`Plumb::Pipeline` offers a sequential, step-by-step syntax for composing processing steps, as well as a simple middleware API to wrap steps for metrics, logging, debugging, caching and more. See the [command objects](https://github.com/ismasan/plumb/blob/main/examples/command_objects.rb) example for a worked use case.
|
1126
|
+
|
1127
|
+
#### `#pipeline` helper
|
861
1128
|
|
862
|
-
|
1129
|
+
All plumb steps have a `#pipeline` helper.
|
1130
|
+
|
1131
|
+
```ruby
|
1132
|
+
User = Types::Data[name: String, age: Integer]
|
1133
|
+
|
1134
|
+
CreateUser = User.pipeline do |pl|
|
1135
|
+
# Add steps as #call(Result) => Result interfaces
|
1136
|
+
pl.step ValidateUser.new
|
1137
|
+
|
1138
|
+
# Or as procs
|
1139
|
+
pl.step do |result|
|
1140
|
+
Logger.info "We have a valid user #{result.value}"
|
1141
|
+
result
|
1142
|
+
end
|
1143
|
+
|
1144
|
+
# Or as other Plumb steps
|
1145
|
+
pl.step User.transform(User) { |user| user.with(name: user.name.upcase) }
|
1146
|
+
|
1147
|
+
pl.step do |result|
|
1148
|
+
DB.create(result.value)
|
1149
|
+
end
|
1150
|
+
end
|
1151
|
+
|
1152
|
+
# User normally as any other Plumb step
|
1153
|
+
result = CreateUser.resolve(name: 'Joe', age: 40)
|
1154
|
+
# result.valid?
|
1155
|
+
# result.errors
|
1156
|
+
# result.value => User
|
1157
|
+
```
|
1158
|
+
|
1159
|
+
Pipelines are Plumb steps, so they can be composed further.
|
1160
|
+
|
1161
|
+
```ruby
|
1162
|
+
IsJoe = User.check('must be named joe') { |user|
|
1163
|
+
result.value.name == 'Joe'
|
1164
|
+
}
|
1165
|
+
|
1166
|
+
CreateIfJoe = IsJoe >> CreateUser
|
1167
|
+
```
|
1168
|
+
|
1169
|
+
##### `#around`
|
1170
|
+
|
1171
|
+
Use `#around` in a pipeline definition to add a middleware step that wraps all other steps registered.
|
1172
|
+
|
1173
|
+
```ruby
|
1174
|
+
# The #around interface is #call(Step, Result::Valid) => Result::Valid | Result::Invalid
|
1175
|
+
StepLogger = proc do |step, result|
|
1176
|
+
Logger.info "Processing step #{step}"
|
1177
|
+
step.call(result)
|
1178
|
+
end
|
1179
|
+
|
1180
|
+
CreateUser = User.pipeline do |pl|
|
1181
|
+
# Around middleware will wrap all other steps registered below
|
1182
|
+
pl.around StepLogger
|
1183
|
+
|
1184
|
+
pl.step ValidateUser.new
|
1185
|
+
pl.step ...etc
|
1186
|
+
end
|
1187
|
+
```
|
1188
|
+
|
1189
|
+
Note that order matters: an _around_ step will only wrap steps registered _after it_.
|
1190
|
+
|
1191
|
+
```ruby
|
1192
|
+
# This step will not be wrapper by StepLogger
|
1193
|
+
pl.step Step1
|
1194
|
+
|
1195
|
+
pl.around StepLogger
|
1196
|
+
# This step WILL be wrapped
|
1197
|
+
pl.step Step2
|
1198
|
+
```
|
1199
|
+
|
1200
|
+
Like regular steps, `around` middleware can be a class, an instance, a proc, or anything that implements the middleware interface.
|
1201
|
+
|
1202
|
+
```ruby
|
1203
|
+
# As class instance
|
1204
|
+
# pl.around StepLogger.new(:warn)
|
1205
|
+
class StepLogger
|
1206
|
+
def initialize(level = :info)
|
1207
|
+
@level = level
|
1208
|
+
end
|
1209
|
+
|
1210
|
+
def call(step, result)
|
1211
|
+
Logger.send(@level) "Processing step #{step}"
|
1212
|
+
step.call(result)
|
1213
|
+
end
|
1214
|
+
end
|
1215
|
+
|
1216
|
+
# As proc
|
1217
|
+
pl.around do |step, result|
|
1218
|
+
Logger.info "Processing step #{step}"
|
1219
|
+
step.call(result)
|
1220
|
+
end
|
1221
|
+
```
|
1222
|
+
|
1223
|
+
#### As stand-alone `Plumb::Pipeline` class
|
1224
|
+
|
1225
|
+
`Plumb::Pipeline` can also be used on its own, sub-classed, and it can take class-level `around` middleware.
|
1226
|
+
|
1227
|
+
```ruby
|
1228
|
+
class LoggedPipeline < Plumb::Pipeline
|
1229
|
+
# class-level midleware will be inherited by sub-classes
|
1230
|
+
around StepLogged
|
1231
|
+
end
|
1232
|
+
|
1233
|
+
# Subclass inherits class-level middleware stack,
|
1234
|
+
# and it can also add its own class or instance-level middleware
|
1235
|
+
class ChildPipeline < LoggedPipeline
|
1236
|
+
# class-level middleware
|
1237
|
+
around Telemetry.new
|
1238
|
+
end
|
1239
|
+
|
1240
|
+
# Instantiate and add instance-level middleware
|
1241
|
+
pipe = ChildPipeline.new do |pl|
|
1242
|
+
pl.around NotifyErrors
|
1243
|
+
pl.step Step1
|
1244
|
+
pl.step Step2
|
1245
|
+
end
|
1246
|
+
```
|
1247
|
+
|
1248
|
+
Sub-classing `Plumb::Pipeline` can be useful to add helpers or domain-specific functionality
|
1249
|
+
|
1250
|
+
```ruby
|
1251
|
+
class DebuggablePipeline < LoggedPipeline
|
1252
|
+
# Use #debug! for inserting a debugger between steps
|
1253
|
+
def debug!
|
1254
|
+
step do |result|
|
1255
|
+
debugger
|
1256
|
+
result
|
1257
|
+
end
|
1258
|
+
end
|
1259
|
+
end
|
1260
|
+
|
1261
|
+
pipe = DebuggablePipeline.new do |pl|
|
1262
|
+
pl.step Step1
|
1263
|
+
pl.debug!
|
1264
|
+
pl.step Step2
|
1265
|
+
end
|
1266
|
+
```
|
1267
|
+
|
1268
|
+
#### Pipelines all the way down :turtle:
|
1269
|
+
|
1270
|
+
Pipelines are full Plumb steps, so they can themselves be used as steps.
|
1271
|
+
|
1272
|
+
```ruby
|
1273
|
+
Pipe1 = DebuggablePipeline.new do |pl|
|
1274
|
+
pl.step Step1
|
1275
|
+
pl.step Step2
|
1276
|
+
end
|
1277
|
+
|
1278
|
+
Pipe2 = DebuggablePipeline.new do |pl|
|
1279
|
+
pl.step Pipe1 # <= A pipeline instance as step
|
1280
|
+
pl.step Step3
|
1281
|
+
end
|
1282
|
+
```
|
1283
|
+
|
1284
|
+
### Plumb::Schema
|
863
1285
|
|
864
1286
|
TODO
|
865
1287
|
|
@@ -898,13 +1320,46 @@ LinkedList = Types::Hash[
|
|
898
1320
|
|
899
1321
|
### Custom types
|
900
1322
|
|
901
|
-
|
1323
|
+
Every Plumb type exposes the following one-method interface:
|
1324
|
+
|
1325
|
+
```
|
1326
|
+
#call(Result::Valid) => Result::Valid | Result::Invalid
|
1327
|
+
```
|
1328
|
+
|
1329
|
+
As long as an object implements this interface, it can be composed into Plumb workflows.
|
1330
|
+
|
1331
|
+
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.
|
1332
|
+
|
1333
|
+
#### Compose procs or lambdas directly
|
1334
|
+
|
1335
|
+
Piping any `#call` object onto Plumb types will wrap your object in a `Plumb::Step` with all methods necessary for further composition.
|
902
1336
|
|
903
1337
|
```ruby
|
904
1338
|
Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
|
905
1339
|
```
|
906
1340
|
|
907
|
-
|
1341
|
+
#### Wrap a `#call` object in `Plumb::Step` explicitely
|
1342
|
+
|
1343
|
+
You can also wrap a proc in `Plumb::Step` explicitly.
|
1344
|
+
|
1345
|
+
```ruby
|
1346
|
+
Greeting = Plumb::Step.new do |result|
|
1347
|
+
result.valid("Hello #{result.value}")
|
1348
|
+
end
|
1349
|
+
```
|
1350
|
+
|
1351
|
+
Note that this example is not prefixed by `Types::String`, so it doesn't first validate that the input is indeed a string.
|
1352
|
+
|
1353
|
+
However, this means that `Greeting` is a `Plumb::Step` which comes with all the Plumb methods and policies.
|
1354
|
+
|
1355
|
+
```ruby
|
1356
|
+
# Greeting responds to #>>, #|, #default, #transform, etc etc
|
1357
|
+
LoudGreeting = Greeting.default('no greeting').invoke(:upcase)
|
1358
|
+
```
|
1359
|
+
|
1360
|
+
#### A custom `#call` class
|
1361
|
+
|
1362
|
+
Or write a custom class that responds to `#call(Result::Valid) => Result::Valid | Result::Invalid`
|
908
1363
|
|
909
1364
|
```ruby
|
910
1365
|
class Greeting
|
@@ -912,6 +1367,9 @@ class Greeting
|
|
912
1367
|
@gr = gr
|
913
1368
|
end
|
914
1369
|
|
1370
|
+
# The Plumb Step interface
|
1371
|
+
# @param result [Plumb::Result::Valid]
|
1372
|
+
# @return [Plumb::Result::Valid, Plumb::Result::Invalid]
|
915
1373
|
def call(result)
|
916
1374
|
result.valid("#{gr} #{result.value}")
|
917
1375
|
end
|
@@ -920,6 +1378,55 @@ end
|
|
920
1378
|
MyType = Types::String >> Greeting.new('Hola')
|
921
1379
|
```
|
922
1380
|
|
1381
|
+
This is useful when you want to parameterize your custom steps, for example by initialising them with arguments like the example above.
|
1382
|
+
|
1383
|
+
#### Include `Plumb::Composable` to make instance of a class full "steps"
|
1384
|
+
|
1385
|
+
The class above will be wrapped by `Plumb::Step` when piped into other steps, but it doesn't support Plumb methods on its own.
|
1386
|
+
|
1387
|
+
Including `Plumb::Composable` makes it support all Plumb methods directly.
|
1388
|
+
|
1389
|
+
```ruby
|
1390
|
+
class Greeting
|
1391
|
+
# This module mixes in Plumb methods such as #>>, #|, #default, #[],
|
1392
|
+
# #transform, #policy, etc etc
|
1393
|
+
include Plumb::Composable
|
1394
|
+
|
1395
|
+
def initialize(gr = 'Hello')
|
1396
|
+
@gr = gr
|
1397
|
+
end
|
1398
|
+
|
1399
|
+
# The Step interface
|
1400
|
+
def call(result)
|
1401
|
+
result.valid("#{gr} #{result.value}")
|
1402
|
+
end
|
1403
|
+
|
1404
|
+
# This is optional, but it allows you to control your object's #inspect
|
1405
|
+
private def _inspect = "Greeting[#{@gr}]"
|
1406
|
+
end
|
1407
|
+
```
|
1408
|
+
|
1409
|
+
Now you can use your class as a composition starting point directly.
|
1410
|
+
|
1411
|
+
```ruby
|
1412
|
+
LoudGreeting = Greeting.new('Hola').default('no greeting').invoke(:upcase)
|
1413
|
+
```
|
1414
|
+
|
1415
|
+
#### Extend a class with `Plumb::Composable` to make the class itself a composable step.
|
1416
|
+
|
1417
|
+
```ruby
|
1418
|
+
class User
|
1419
|
+
extend Composable
|
1420
|
+
|
1421
|
+
def self.class(result)
|
1422
|
+
# do something here. Perhaps returning a Result with an instance of this class
|
1423
|
+
return result.valid(new)
|
1424
|
+
end
|
1425
|
+
end
|
1426
|
+
```
|
1427
|
+
|
1428
|
+
This is how [Plumb::Types::Data](#typesdata) is implemented.
|
1429
|
+
|
923
1430
|
### Custom policies
|
924
1431
|
|
925
1432
|
`Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
|
@@ -946,8 +1453,6 @@ The `#policy` helper supports applying multiply policies.
|
|
946
1453
|
Types::String.policy(default_if_nil: 'nothing here', size: (10..20))
|
947
1454
|
```
|
948
1455
|
|
949
|
-
|
950
|
-
|
951
1456
|
#### Policies as helper methods
|
952
1457
|
|
953
1458
|
Use the `helper: true` option to register the policy as a method you can call on types directly.
|
@@ -963,9 +1468,21 @@ StringWithDefault = Types::String.default_if_nil('nothing here')
|
|
963
1468
|
|
964
1469
|
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
1470
|
|
1471
|
+
This other example adds a boolean to type metadata.
|
1472
|
+
|
1473
|
+
```ruby
|
1474
|
+
Plumb.policy :admin, helper: true do |type|
|
1475
|
+
type.metadata(admin: true)
|
1476
|
+
end
|
1477
|
+
|
1478
|
+
# Usage: annotate fields in a schema
|
1479
|
+
AccountName = Types::String.admin
|
1480
|
+
AccountName.metadata # => { type: String, admin: true }
|
1481
|
+
```
|
1482
|
+
|
966
1483
|
#### Type-specific policies
|
967
1484
|
|
968
|
-
You can use the `for_type:` option to define policies that only apply to steps that output certain types. This example only
|
1485
|
+
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.
|
969
1486
|
|
970
1487
|
```ruby
|
971
1488
|
Plumb.policy :multiply_by, for_type: Integer, helper: true do |type, factor|
|
@@ -975,7 +1492,7 @@ end
|
|
975
1492
|
Doubled = Types::Integer.multiply_by(2)
|
976
1493
|
Doubled.parse(2) # 4
|
977
1494
|
|
978
|
-
#
|
1495
|
+
# Trying to apply this policy to a non Integer will raise an exception
|
979
1496
|
DoubledString = Types::String.multiply_by(2) # raises error
|
980
1497
|
```
|
981
1498
|
|
@@ -997,7 +1514,7 @@ DoubledMoney = Types::Any[Money].multiply_by(2)
|
|
997
1514
|
|
998
1515
|
#### Self-contained policy modules
|
999
1516
|
|
1000
|
-
You can register a module, class or
|
1517
|
+
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.
|
1001
1518
|
|
1002
1519
|
```ruby
|
1003
1520
|
module SplitPolicy
|
@@ -1016,6 +1533,21 @@ Plumb.policy :split, SplitPolicy
|
|
1016
1533
|
|
1017
1534
|
### JSON Schema
|
1018
1535
|
|
1536
|
+
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.
|
1537
|
+
|
1538
|
+
```ruby
|
1539
|
+
Payload = Types::Hash[name: String]
|
1540
|
+
Payload.to_json_schema(root: true)
|
1541
|
+
# {
|
1542
|
+
# "$schema"=>"https://json-schema.org/draft-08/schema#",
|
1543
|
+
# "type"=>"object",
|
1544
|
+
# "properties"=>{"name"=>{"type"=>"string"}},
|
1545
|
+
# "required"=>["name"]
|
1546
|
+
# }
|
1547
|
+
```
|
1548
|
+
|
1549
|
+
The visitor can be used directly, too.
|
1550
|
+
|
1019
1551
|
```ruby
|
1020
1552
|
User = Types::Hash[
|
1021
1553
|
name: Types::String,
|
@@ -1035,7 +1567,7 @@ json_schema = Plumb::JSONSchemaVisitor.call(User)
|
|
1035
1567
|
}
|
1036
1568
|
```
|
1037
1569
|
|
1038
|
-
The built-in JSON Schema generator handles most standard types and compositions. You can add or override
|
1570
|
+
The built-in JSON Schema generator handles most standard types and compositions. You can add or override handlers on a per-type basis with:
|
1039
1571
|
|
1040
1572
|
```ruby
|
1041
1573
|
Plumb::JSONSchemaVisitor.on(:not) do |node, props|
|
@@ -1047,11 +1579,31 @@ type = Types::Decimal.not
|
|
1047
1579
|
schema = Plumb::JSONSchemaVisitor.visit(type) # { 'not' => { 'type' => 'number' } }
|
1048
1580
|
```
|
1049
1581
|
|
1050
|
-
|
1582
|
+
You can also register custom classes or types that are wrapped by Plumb steps.
|
1583
|
+
|
1584
|
+
```ruby
|
1585
|
+
module Types
|
1586
|
+
DateTime = Any[::DateTime]
|
1587
|
+
end
|
1588
|
+
|
1589
|
+
Plumb::JSONSchemaVisitor.on(::DateTime) do |node, props|
|
1590
|
+
props.merge('type' => 'string', 'format' => 'date-time')
|
1591
|
+
end
|
1592
|
+
|
1593
|
+
Types::DateTime.to_json_schema
|
1594
|
+
# {"type"=>"string", "format"=>"date-time"}
|
1595
|
+
```
|
1596
|
+
|
1051
1597
|
|
1052
|
-
TODO. See `Plumb::JSONSchemaVisitor`.
|
1053
1598
|
|
1599
|
+
## TODO:
|
1054
1600
|
|
1601
|
+
- [ ] benchmarks and performace. Compare with `Parametric`, `ActiveModel::Attributes`, `ActionController::StrongParameters`
|
1602
|
+
- [ ] flesh out `Plumb::Schema`
|
1603
|
+
- [x] `Plumb::Struct`
|
1604
|
+
- [x] flesh out and document `Plumb::Pipeline`
|
1605
|
+
- [ ] document custom visitors
|
1606
|
+
- [ ] Improve errors, support I18n ?
|
1055
1607
|
|
1056
1608
|
## Development
|
1057
1609
|
|