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