plumb 0.0.3 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +609 -57
  3. data/bench/compare_parametric_schema.rb +102 -0
  4. data/bench/compare_parametric_struct.rb +68 -0
  5. data/bench/parametric_schema.rb +229 -0
  6. data/bench/plumb_hash.rb +109 -0
  7. data/examples/concurrent_downloads.rb +3 -3
  8. data/examples/env_config.rb +2 -2
  9. data/examples/event_registry.rb +127 -0
  10. data/examples/weekdays.rb +1 -1
  11. data/lib/plumb/and.rb +4 -3
  12. data/lib/plumb/any_class.rb +4 -4
  13. data/lib/plumb/array_class.rb +8 -5
  14. data/lib/plumb/attributes.rb +268 -0
  15. data/lib/plumb/build.rb +4 -3
  16. data/lib/plumb/composable.rb +381 -0
  17. data/lib/plumb/decorator.rb +57 -0
  18. data/lib/plumb/deferred.rb +1 -1
  19. data/lib/plumb/hash_class.rb +19 -8
  20. data/lib/plumb/hash_map.rb +8 -6
  21. data/lib/plumb/interface_class.rb +6 -2
  22. data/lib/plumb/json_schema_visitor.rb +59 -32
  23. data/lib/plumb/match_class.rb +5 -4
  24. data/lib/plumb/metadata.rb +5 -1
  25. data/lib/plumb/metadata_visitor.rb +13 -42
  26. data/lib/plumb/not.rb +4 -3
  27. data/lib/plumb/or.rb +10 -4
  28. data/lib/plumb/pipeline.rb +27 -7
  29. data/lib/plumb/policy.rb +10 -3
  30. data/lib/plumb/schema.rb +11 -10
  31. data/lib/plumb/static_class.rb +4 -3
  32. data/lib/plumb/step.rb +4 -3
  33. data/lib/plumb/stream_class.rb +8 -7
  34. data/lib/plumb/tagged_hash.rb +11 -11
  35. data/lib/plumb/transform.rb +4 -3
  36. data/lib/plumb/tuple_class.rb +8 -8
  37. data/lib/plumb/type_registry.rb +5 -2
  38. data/lib/plumb/types.rb +30 -1
  39. data/lib/plumb/value_class.rb +4 -3
  40. data/lib/plumb/version.rb +1 -1
  41. data/lib/plumb/visitor_handlers.rb +6 -0
  42. data/lib/plumb.rb +11 -5
  43. metadata +10 -3
  44. 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
- TODO
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 = Types::Any[::String]
51
- Integer = Types::Any[::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::TypeError)
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::TypeError)
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::TypeError
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 and hash maps, and it's possible to create your own composite types.
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
- ## Type composition
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 _and_, _or_ and _not_ semantics. Everything else builds on top of these two ideas.
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
- ### Composing types with `#>>` ("And")
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
- ### Disjunction with `#|` ("Or")
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::TypeError
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
- ## Composing with `#>>` and `#|`
177
+ #### Composing with `#>>` and `#|`
164
178
 
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.
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
- ## Built-in types
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 methods that encapsulate common compositions. Plumb ships with some, listed below, and you can also define your own.
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
- ### `#present`
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
- ### `#nullable`
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) # TypeError
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
- ### `#not`
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
- ### `#options`
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
- ### `#transform`
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
- ### `#invoke`
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
- That that, as opposed to `#transform`, this modified does not register a type in `#metadata[:type]`, which can be valuable for introspection or documentation (ex. JSON Schema).
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
- ### `#default`
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
- ### `#build`
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
- ### `#check`
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
- ### `#meta` and `#metadata`
450
+ #### `#metadata`
422
451
 
423
452
  Add metadata to a type
424
453
 
425
454
  ```ruby
426
- type = Types::String.meta(description: 'A long text')
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.meta(description: 'A long text') >> Types::String.match(/@/).meta(note: 'An email address')
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: Types::Static['name_updated'], name: Types::String]
621
- AgeUpdatedEvent = Types::Hash[type: Types::Static['age_updated'], age: Types::Integer]
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 agains the regular expression.
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
- ### Plumb::Schema
928
+ ### Types::Data
855
929
 
856
- TODO
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
- TODO
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
- ### Plumb::Struct
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
- Compose procs or lambdas directly
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
- or a custom class that responds to `#call(Result::Valid) => Result::Valid | Result::Invalid`
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 applies for types that return `Integer` values.
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
- # Tryin to apply this policy to a non Integer will raise an exception
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 module with a three-method interface as a policy. This is so that policies can have their own namespace if they need local constants or private methods. For example, this is how the `:split` policy for strings is defined.
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 handles on a per-type basis with:
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
- #### JSON Schema handlers for custom policies
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