plumb 0.0.2 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
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