plumb 0.0.3 → 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 +391 -52
- data/examples/concurrent_downloads.rb +3 -3
- data/examples/env_config.rb +2 -2
- data/examples/event_registry.rb +120 -0
- 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} +61 -28
- 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 +50 -32
- data/lib/plumb/match_class.rb +4 -3
- 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 +6 -5
- 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 +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 +6 -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 +6 -3
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,8 @@ 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
|
+
|
42
48
|
### Specialize your types with `#[]`
|
43
49
|
|
44
50
|
Use `#[]` to make your types match a class.
|
@@ -47,8 +53,8 @@ Use `#[]` to make your types match a class.
|
|
47
53
|
module Types
|
48
54
|
include Plumb::Types
|
49
55
|
|
50
|
-
String =
|
51
|
-
Integer =
|
56
|
+
String = Any[::String]
|
57
|
+
Integer = Any[::Integer]
|
52
58
|
end
|
53
59
|
|
54
60
|
Types::String.parse("hello") # => "hello"
|
@@ -127,13 +133,13 @@ joe = User.parse({ name: 'Joe', email: 'joe@email.com', age: 20}) # returns vali
|
|
127
133
|
Users.parse([joe]) # returns valid array of user hashes
|
128
134
|
```
|
129
135
|
|
130
|
-
More about [Types::Array](#typeshash) and [Types::Array](#typesarray). There's also tuples
|
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.
|
131
137
|
|
132
|
-
|
138
|
+
### Type composition
|
133
139
|
|
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
|
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.
|
135
141
|
|
136
|
-
|
142
|
+
#### Composing types with `#>>` ("And")
|
137
143
|
|
138
144
|
```ruby
|
139
145
|
Email = Types::String[/@/]
|
@@ -143,7 +149,13 @@ Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") }
|
|
143
149
|
Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
144
150
|
```
|
145
151
|
|
146
|
-
|
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."
|
147
159
|
|
148
160
|
```ruby
|
149
161
|
StringOrInt = Types::String | Types::Integer
|
@@ -160,9 +172,13 @@ EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
|
160
172
|
EmailOrDefault.parse('nope') # "no email"
|
161
173
|
```
|
162
174
|
|
163
|
-
|
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)`
|
164
180
|
|
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.
|
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.
|
166
182
|
|
167
183
|
```ruby
|
168
184
|
require 'money'
|
@@ -170,12 +186,22 @@ require 'money'
|
|
170
186
|
module Types
|
171
187
|
include Plumb::Types
|
172
188
|
|
189
|
+
# Match any Money instance
|
173
190
|
Money = Any[::Money]
|
191
|
+
|
192
|
+
# Transform Integers into Money instances
|
174
193
|
IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') }
|
194
|
+
|
195
|
+
# Transform integer-looking Strings into Integers
|
175
196
|
StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i)
|
197
|
+
|
198
|
+
# Validate that a Money instance is USD
|
176
199
|
USD = Money.check { |amount| amount.currency.code == 'UDS' }
|
200
|
+
|
201
|
+
# Exchange a non-USD Money instance into USD
|
177
202
|
ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') }
|
178
203
|
|
204
|
+
# Compose a pipeline that accepts Strings, Integers or Money and returns USD money.
|
179
205
|
FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD)
|
180
206
|
end
|
181
207
|
|
@@ -184,9 +210,9 @@ FlexibleUSD.parse(1000) # Money(USD 10.00)
|
|
184
210
|
FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
|
185
211
|
```
|
186
212
|
|
187
|
-
You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/examples)
|
213
|
+
You can see more use cases in [the examples directory](https://github.com/ismasan/plumb/tree/main/examples)
|
188
214
|
|
189
|
-
|
215
|
+
### Built-in types
|
190
216
|
|
191
217
|
* `Types::Value`
|
192
218
|
* `Types::Array`
|
@@ -196,7 +222,6 @@ You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/
|
|
196
222
|
* `Types::Interface`
|
197
223
|
* `Types::False`
|
198
224
|
* `Types::Tuple`
|
199
|
-
* `Types::Split`
|
200
225
|
* `Types::Any`
|
201
226
|
* `Types::Static`
|
202
227
|
* `Types::Undefined`
|
@@ -213,13 +238,13 @@ You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/
|
|
213
238
|
* `Types::Forms::True`
|
214
239
|
* `Types::Forms::False`
|
215
240
|
|
216
|
-
|
241
|
+
TODO: date and datetime, UUIDs, Email, others.
|
217
242
|
|
218
243
|
### Policies
|
219
244
|
|
220
|
-
Policies are
|
245
|
+
Policies are helpers that encapsulate common compositions. Plumb ships with some handy ones, listed below, and you can also define your own.
|
221
246
|
|
222
|
-
|
247
|
+
#### `#present`
|
223
248
|
|
224
249
|
Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`)
|
225
250
|
|
@@ -228,7 +253,7 @@ Types::String.present.resolve('') # Failure with errors
|
|
228
253
|
Types::Array[Types::String].resolve([]) # Failure with errors
|
229
254
|
```
|
230
255
|
|
231
|
-
|
256
|
+
#### `#nullable`
|
232
257
|
|
233
258
|
Allow `nil` values.
|
234
259
|
|
@@ -245,7 +270,7 @@ Note that this just encapsulates the following composition:
|
|
245
270
|
nullable_str = Types::String | Types::Nil
|
246
271
|
```
|
247
272
|
|
248
|
-
|
273
|
+
#### `#not`
|
249
274
|
|
250
275
|
Negates a type.
|
251
276
|
```ruby
|
@@ -255,7 +280,7 @@ NotEmail.parse('hello') # "hello"
|
|
255
280
|
NotEmail.parse('hello@server.com') # error
|
256
281
|
```
|
257
282
|
|
258
|
-
|
283
|
+
#### `#options`
|
259
284
|
|
260
285
|
Sets allowed options for value.
|
261
286
|
|
@@ -273,7 +298,7 @@ type.resolve(['a', 'a', 'b']) # Valid
|
|
273
298
|
type.resolve(['a', 'x', 'b']) # Failure
|
274
299
|
```
|
275
300
|
|
276
|
-
|
301
|
+
#### `#transform`
|
277
302
|
|
278
303
|
Transform value. Requires specifying the resulting type of the value after transformation.
|
279
304
|
|
@@ -285,7 +310,7 @@ StringToInt = Types::String.transform(Integer, &:to_i)
|
|
285
310
|
StringToInteger.parse('10') # => 10
|
286
311
|
```
|
287
312
|
|
288
|
-
|
313
|
+
#### `#invoke`
|
289
314
|
|
290
315
|
`#invoke` builds a Step that will invoke one or more methods on the value.
|
291
316
|
|
@@ -311,7 +336,7 @@ UpcaseToSym = Types::String.invoke(%i[downcase to_sym])
|
|
311
336
|
UpcaseToSym.parse('FOO_BAR') # :foo_bar
|
312
337
|
```
|
313
338
|
|
314
|
-
|
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).
|
315
340
|
|
316
341
|
Also, there's no definition-time checks that the method names are actually supported by the input values.
|
317
342
|
|
@@ -322,7 +347,7 @@ type.parse([1, 2]) # raises NoMethodError because Array doesn't respond to #stri
|
|
322
347
|
|
323
348
|
Use with caution.
|
324
349
|
|
325
|
-
|
350
|
+
#### `#default`
|
326
351
|
|
327
352
|
Default value when no value given (ie. when key is missing in Hash payloads. See `Types::Hash` below).
|
328
353
|
|
@@ -356,7 +381,7 @@ Same if you want to apply a default to several cases.
|
|
356
381
|
str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
|
357
382
|
```
|
358
383
|
|
359
|
-
|
384
|
+
#### `#build`
|
360
385
|
|
361
386
|
Build a custom object or class.
|
362
387
|
|
@@ -389,7 +414,7 @@ Note that this case is identical to `#transform` with a block.
|
|
389
414
|
StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
|
390
415
|
```
|
391
416
|
|
392
|
-
|
417
|
+
#### `#check`
|
393
418
|
|
394
419
|
Pass the value through an arbitrary validation
|
395
420
|
|
@@ -399,9 +424,7 @@ type.parse('Role: Manager') # 'Role: Manager'
|
|
399
424
|
type.parse('Manager') # fails
|
400
425
|
```
|
401
426
|
|
402
|
-
|
403
|
-
|
404
|
-
### `#value`
|
427
|
+
#### `#value`
|
405
428
|
|
406
429
|
Constrain a type to a specific value. Compares with `#==`
|
407
430
|
|
@@ -418,19 +441,21 @@ All scalar types support this:
|
|
418
441
|
ten = Types::Integer.value(10)
|
419
442
|
```
|
420
443
|
|
421
|
-
|
444
|
+
#### `#metadata`
|
422
445
|
|
423
446
|
Add metadata to a type
|
424
447
|
|
425
448
|
```ruby
|
426
|
-
|
449
|
+
# A new type with metadata
|
450
|
+
type = Types::String.metadata(description: 'A long text')
|
451
|
+
# Read a type's metadata
|
427
452
|
type.metadata[:description] # 'A long text'
|
428
453
|
```
|
429
454
|
|
430
455
|
`#metadata` combines keys from type compositions.
|
431
456
|
|
432
457
|
```ruby
|
433
|
-
type = Types::String.
|
458
|
+
type = Types::String.metadata(description: 'A long text') >> Types::String.match(/@/).metadata(note: 'An email address')
|
434
459
|
type.metadata[:description] # 'A long text'
|
435
460
|
type.metadata[:note] # 'An email address'
|
436
461
|
```
|
@@ -614,11 +639,9 @@ intersection = User & Employee # Hash[:name]
|
|
614
639
|
|
615
640
|
Use `#tagged_by` to resolve what definition to use based on the value of a common key.
|
616
641
|
|
617
|
-
Key used as index must be a `Types::Static`
|
618
|
-
|
619
642
|
```ruby
|
620
|
-
NameUpdatedEvent = Types::Hash[type:
|
621
|
-
AgeUpdatedEvent = Types::Hash[type:
|
643
|
+
NameUpdatedEvent = Types::Hash[type: 'name_updated', name: Types::String]
|
644
|
+
AgeUpdatedEvent = Types::Hash[type: 'age_updated', age: Types::Integer]
|
622
645
|
|
623
646
|
Events = Types::Hash.tagged_by(
|
624
647
|
:type,
|
@@ -664,7 +687,7 @@ InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
|
|
664
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.
|
665
688
|
|
666
689
|
```ruby
|
667
|
-
User = Types::Hash[name: String, age: Integer]
|
690
|
+
User = Types::Hash[name: String, age: Integer].filtered
|
668
691
|
User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
|
669
692
|
User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
|
670
693
|
```
|
@@ -729,7 +752,7 @@ emails = Types::Array[/@/]
|
|
729
752
|
emails = Types::Array[Types::String[/@/]]
|
730
753
|
```
|
731
754
|
|
732
|
-
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.
|
733
756
|
|
734
757
|
#### Concurrent arrays
|
735
758
|
|
@@ -851,15 +874,201 @@ Str.parse(data).each do |row|
|
|
851
874
|
end
|
852
875
|
```
|
853
876
|
|
854
|
-
###
|
877
|
+
### Types::Data
|
855
878
|
|
856
|
-
|
879
|
+
`Types::Data` provides a superclass to define **inmutable** structs or value objects with typed / coercible attributes.
|
857
880
|
|
858
|
-
|
881
|
+
#### `[]` Syntax
|
882
|
+
|
883
|
+
The `[]` syntax is a short-hand for struct definition.
|
884
|
+
Like `Plumb::Types::Hash`, suffixing a key with `?` makes it optional.
|
885
|
+
|
886
|
+
```ruby
|
887
|
+
Person = Types::Data[name: String, age?: Integer]
|
888
|
+
person = Person.new(name: 'Jane')
|
889
|
+
```
|
890
|
+
|
891
|
+
This syntax creates subclasses too.
|
892
|
+
|
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`
|
899
|
+
|
900
|
+
```ruby
|
901
|
+
person = Person.new(name: 'Joe')
|
902
|
+
person.name # 'Joe'
|
903
|
+
person.valid? # false
|
904
|
+
person.errors[:age] # 'must be an integer'
|
905
|
+
```
|
906
|
+
|
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)
|
913
|
+
```
|
914
|
+
|
915
|
+
#### `.attribute` syntax
|
916
|
+
|
917
|
+
This syntax allows defining struct classes with typed attributes, including nested structs.
|
918
|
+
|
919
|
+
```ruby
|
920
|
+
class Person < Types::Data
|
921
|
+
attribute :name, Types::String.present
|
922
|
+
attribute :age, Types::Integer
|
923
|
+
end
|
924
|
+
```
|
925
|
+
|
926
|
+
It supports nested attributes:
|
927
|
+
|
928
|
+
```ruby
|
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
|
937
|
+
```
|
938
|
+
|
939
|
+
Or arrays of nested attributes:
|
940
|
+
|
941
|
+
```ruby
|
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
|
950
|
+
|
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
|
959
|
+
end
|
960
|
+
|
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
|
+
]
|
1048
|
+
```
|
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
|
859
1068
|
|
860
1069
|
TODO
|
861
1070
|
|
862
|
-
### Plumb::
|
1071
|
+
### Plumb::Pipeline
|
863
1072
|
|
864
1073
|
TODO
|
865
1074
|
|
@@ -898,13 +1107,46 @@ LinkedList = Types::Hash[
|
|
898
1107
|
|
899
1108
|
### Custom types
|
900
1109
|
|
901
|
-
|
1110
|
+
Every Plumb type exposes the following one-method interface:
|
1111
|
+
|
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.
|
1117
|
+
|
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.
|
902
1123
|
|
903
1124
|
```ruby
|
904
1125
|
Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
|
905
1126
|
```
|
906
1127
|
|
907
|
-
|
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`
|
908
1150
|
|
909
1151
|
```ruby
|
910
1152
|
class Greeting
|
@@ -912,6 +1154,9 @@ class Greeting
|
|
912
1154
|
@gr = gr
|
913
1155
|
end
|
914
1156
|
|
1157
|
+
# The Plumb Step interface
|
1158
|
+
# @param result [Plumb::Result::Valid]
|
1159
|
+
# @return [Plumb::Result::Valid, Plumb::Result::Invalid]
|
915
1160
|
def call(result)
|
916
1161
|
result.valid("#{gr} #{result.value}")
|
917
1162
|
end
|
@@ -920,6 +1165,55 @@ end
|
|
920
1165
|
MyType = Types::String >> Greeting.new('Hola')
|
921
1166
|
```
|
922
1167
|
|
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.
|
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
|
+
|
923
1217
|
### Custom policies
|
924
1218
|
|
925
1219
|
`Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
|
@@ -946,8 +1240,6 @@ The `#policy` helper supports applying multiply policies.
|
|
946
1240
|
Types::String.policy(default_if_nil: 'nothing here', size: (10..20))
|
947
1241
|
```
|
948
1242
|
|
949
|
-
|
950
|
-
|
951
1243
|
#### Policies as helper methods
|
952
1244
|
|
953
1245
|
Use the `helper: true` option to register the policy as a method you can call on types directly.
|
@@ -963,9 +1255,21 @@ StringWithDefault = Types::String.default_if_nil('nothing here')
|
|
963
1255
|
|
964
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!).
|
965
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
|
+
|
966
1270
|
#### Type-specific policies
|
967
1271
|
|
968
|
-
You can use the `for_type:` option to define policies that only apply to steps that output certain types. This example only
|
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.
|
969
1273
|
|
970
1274
|
```ruby
|
971
1275
|
Plumb.policy :multiply_by, for_type: Integer, helper: true do |type, factor|
|
@@ -975,7 +1279,7 @@ end
|
|
975
1279
|
Doubled = Types::Integer.multiply_by(2)
|
976
1280
|
Doubled.parse(2) # 4
|
977
1281
|
|
978
|
-
#
|
1282
|
+
# Trying to apply this policy to a non Integer will raise an exception
|
979
1283
|
DoubledString = Types::String.multiply_by(2) # raises error
|
980
1284
|
```
|
981
1285
|
|
@@ -997,7 +1301,7 @@ DoubledMoney = Types::Any[Money].multiply_by(2)
|
|
997
1301
|
|
998
1302
|
#### Self-contained policy modules
|
999
1303
|
|
1000
|
-
You can register a module, class or
|
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.
|
1001
1305
|
|
1002
1306
|
```ruby
|
1003
1307
|
module SplitPolicy
|
@@ -1016,6 +1320,21 @@ Plumb.policy :split, SplitPolicy
|
|
1016
1320
|
|
1017
1321
|
### JSON Schema
|
1018
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
|
+
|
1019
1338
|
```ruby
|
1020
1339
|
User = Types::Hash[
|
1021
1340
|
name: Types::String,
|
@@ -1035,7 +1354,7 @@ json_schema = Plumb::JSONSchemaVisitor.call(User)
|
|
1035
1354
|
}
|
1036
1355
|
```
|
1037
1356
|
|
1038
|
-
The built-in JSON Schema generator handles most standard types and compositions. You can add or override
|
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:
|
1039
1358
|
|
1040
1359
|
```ruby
|
1041
1360
|
Plumb::JSONSchemaVisitor.on(:not) do |node, props|
|
@@ -1047,11 +1366,31 @@ type = Types::Decimal.not
|
|
1047
1366
|
schema = Plumb::JSONSchemaVisitor.visit(type) # { 'not' => { 'type' => 'number' } }
|
1048
1367
|
```
|
1049
1368
|
|
1050
|
-
|
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
|
+
|
1051
1384
|
|
1052
|
-
TODO. See `Plumb::JSONSchemaVisitor`.
|
1053
1385
|
|
1386
|
+
## TODO:
|
1054
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 ?
|
1055
1394
|
|
1056
1395
|
## Development
|
1057
1396
|
|