plumb 0.0.2 → 0.0.4

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