plumb 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db1a6e5f70bf36e91d053ff465e9f566cd4371e4620dac88aea6f028918311a8
4
- data.tar.gz: 13e986c5a7815c3ecbdf6f1f6cabb4d9341b88421d8048a7e7182d9b638e1632
3
+ metadata.gz: 2f8b93e98170481b05c028f2ffa16c3f52cb57347efb373671cf4fbd915ca153
4
+ data.tar.gz: 89ae563b7e75ee76c2672f195ef94553123e41a6fffebc6835033f6401c7506a
5
5
  SHA512:
6
- metadata.gz: 540cb16d4ab114931dad7b278578428357341e5318f5371a40d22b6d53714fe71290cb66892725316faf2060e5a1ca8e4c318951e9dc8eeb7839eac2a38d4800
7
- data.tar.gz: 6404ab512cb061af57be9d7b3e41dfb967e3e651819f4fb540a6e018f55a5ab18a976649fe891338181f7e769b9efb0a29df1e8dd0586471b14ea0b7b04a511d
6
+ metadata.gz: acaa62117892affe3eb921f76ade115f21f0dda711492a3ce9265f1ad859bf25b4dfecc1e9f920058d716b54d4ae1389c06b2d716ef9eac49d977eb4638d1db7
7
+ data.tar.gz: 200dc5ba480ddf4fedee46afa7c5b8e5c6191b8c796cb31df4b246841826aac9d8df181be2c3b5c90b53739fa791c3dc6c2b3d014a6822db74121f81873fa145
data/README.md CHANGED
@@ -39,6 +39,52 @@ result.valid? # false
39
39
  result.errors # ""
40
40
  ```
41
41
 
42
+ ### Specialize your types with `#[]`
43
+
44
+ Use `#[]` to make your types match a class.
45
+
46
+ ```ruby
47
+ module Types
48
+ include Plumb::Types
49
+
50
+ String = Types::Any[::String]
51
+ Integer = Types::Any[::Integer]
52
+ end
53
+
54
+ Types::String.parse("hello") # => "hello"
55
+ Types::String.parse(10) # raises "Must be a String" (Plumb::TypeError)
56
+ ```
57
+
58
+ Plumb ships with basic types already defined, such as `Types::String` and `Types::Integer`. See the full list below.
59
+
60
+ The `#[]` method is not just for classes. It works with anything that responds to `#===`
61
+
62
+ ```ruby
63
+ # Match against a regex
64
+ Email = Types::String[/@/] # ie Types::Any[String][/@/]
65
+
66
+ Email.parse('hello') # fails
67
+ Email.parse('hello@server.com') # 'hello@server.com'
68
+
69
+ # Or a Range
70
+ AdultAge = Types::Integer[18..]
71
+ AdultAge.parse(20) # 20
72
+ AdultAge.parse(17) # raises "Must be within 18.."" (Plumb::TypeError)
73
+
74
+ # Or literal values
75
+ Twenty = Types::Integer[20]
76
+ Twenty.parse(20) # 20
77
+ Twenty.parse(21) # type error
78
+ ```
79
+
80
+ It can be combined with other methods. For example to cast strings as integers, but only if they _look_ like integers.
81
+
82
+ ```ruby
83
+ StringToInt = Types::String[/^\d+$/].transform(::Integer, &:to_i)
84
+
85
+ StringToInt.parse('100') # => 100
86
+ StringToInt.parse('100lol') # fails
87
+ ```
42
88
 
43
89
  ### `#resolve(value) => Result`
44
90
 
@@ -55,8 +101,6 @@ result.value # '10'
55
101
  result.errors # 'must be an Integer'
56
102
  ```
57
103
 
58
-
59
-
60
104
  ### `#parse(value) => value`
61
105
 
62
106
  `#parse` takes an input value and returns the parsed/coerced value if successful. or it raises an exception if failed.
@@ -68,6 +112,80 @@ Types::Integer.parse('10') # raises Plumb::TypeError
68
112
 
69
113
 
70
114
 
115
+ ### Composite types
116
+
117
+ Some built-in types such as `Types::Array` and `Types::Hash` allow defininig array or hash data structures composed of other types.
118
+
119
+ ```ruby
120
+ # A user hash
121
+ User = Types::Hash[name: Types::String, email: Email, age: AdultAge]
122
+
123
+ # An array of User hashes
124
+ Users = Types::Array[User]
125
+
126
+ joe = User.parse({ name: 'Joe', email: 'joe@email.com', age: 20}) # returns valid hash
127
+ Users.parse([joe]) # returns valid array of user hashes
128
+ ```
129
+
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.
131
+
132
+ ## Type composition
133
+
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.
135
+
136
+ ### Composing types with `#>>` ("And")
137
+
138
+ ```ruby
139
+ Email = Types::String[/@/]
140
+ # You can compose procs and lambdas, or other types.
141
+ Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") }
142
+
143
+ Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
144
+ ```
145
+
146
+ ### Disjunction with `#|` ("Or")
147
+
148
+ ```ruby
149
+ StringOrInt = Types::String | Types::Integer
150
+ StringOrInt.parse('hello') # "hello"
151
+ StringOrInt.parse(10) # 10
152
+ StringOrInt.parse({}) # raises Plumb::TypeError
153
+ ```
154
+
155
+ Custom default value logic for non-emails
156
+
157
+ ```ruby
158
+ EmailOrDefault = Greeting | Types::Static['no email']
159
+ EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
160
+ EmailOrDefault.parse('nope') # "no email"
161
+ ```
162
+
163
+ ## Composing with `#>>` and `#|`
164
+
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.
166
+
167
+ ```ruby
168
+ require 'money'
169
+
170
+ module Types
171
+ include Plumb::Types
172
+
173
+ Money = Any[::Money]
174
+ IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') }
175
+ StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i)
176
+ USD = Money.check { |amount| amount.currency.code == 'UDS' }
177
+ ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') }
178
+
179
+ FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD)
180
+ end
181
+
182
+ FlexibleUSD.parse('1000') # Money(USD 10.00)
183
+ FlexibleUSD.parse(1000) # Money(USD 10.00)
184
+ FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
185
+ ```
186
+
187
+ You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/examples)
188
+
71
189
  ## Built-in types
72
190
 
73
191
  * `Types::Value`
@@ -79,12 +197,10 @@ Types::Integer.parse('10') # raises Plumb::TypeError
79
197
  * `Types::False`
80
198
  * `Types::Tuple`
81
199
  * `Types::Split`
82
- * `Types::Blank`
83
200
  * `Types::Any`
84
201
  * `Types::Static`
85
202
  * `Types::Undefined`
86
203
  * `Types::Nil`
87
- * `Types::Present`
88
204
  * `Types::Integer`
89
205
  * `Types::Numeric`
90
206
  * `Types::String`
@@ -99,6 +215,10 @@ Types::Integer.parse('10') # raises Plumb::TypeError
99
215
 
100
216
 
101
217
 
218
+ ### Policies
219
+
220
+ Policies are methods that encapsulate common compositions. Plumb ships with some, listed below, and you can also define your own.
221
+
102
222
  ### `#present`
103
223
 
104
224
  Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`)
@@ -119,14 +239,12 @@ nullable_str.parse('hello') # 'hello'
119
239
  nullable_str.parse(10) # TypeError
120
240
  ```
121
241
 
122
- Note that this is syntax sugar for
242
+ Note that this just encapsulates the following composition:
123
243
 
124
244
  ```ruby
125
245
  nullable_str = Types::String | Types::Nil
126
246
  ```
127
247
 
128
-
129
-
130
248
  ### `#not`
131
249
 
132
250
  Negates a type.
@@ -155,8 +273,6 @@ type.resolve(['a', 'a', 'b']) # Valid
155
273
  type.resolve(['a', 'x', 'b']) # Failure
156
274
  ```
157
275
 
158
-
159
-
160
276
  ### `#transform`
161
277
 
162
278
  Transform value. Requires specifying the resulting type of the value after transformation.
@@ -240,45 +356,6 @@ Same if you want to apply a default to several cases.
240
356
  str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
241
357
  ```
242
358
 
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
359
  ### `#build`
283
360
 
284
361
  Build a custom object or class.
@@ -312,8 +389,6 @@ Note that this case is identical to `#transform` with a block.
312
389
  StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
313
390
  ```
314
391
 
315
-
316
-
317
392
  ### `#check`
318
393
 
319
394
  Pass the value through an arbitrary validation
@@ -343,8 +418,6 @@ All scalar types support this:
343
418
  ten = Types::Integer.value(10)
344
419
  ```
345
420
 
346
-
347
-
348
421
  ### `#meta` and `#metadata`
349
422
 
350
423
  Add metadata to a type
@@ -373,7 +446,60 @@ Types::String.transform(Integer, &:to_i).metadata[:type] # Integer
373
446
 
374
447
  TODO: document custom visitors.
375
448
 
376
- ## `Types::Hash`
449
+ ### Other policies
450
+
451
+ 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.
452
+
453
+ #### `:respond_to`
454
+
455
+ Similar to `Types::Interface`, this is a quick way to assert that a value supports one or more methods.
456
+
457
+ ```ruby
458
+ List = Types::Any.policy(respond_to: :each)
459
+ # or
460
+ List = Types::Any.policy(respond_to: [:each, :[], :size)
461
+ ```
462
+
463
+ #### `:excluded_from`
464
+
465
+ The opposite of `#options`, this policy validates that the value _is not_ included in a list.
466
+
467
+ ```ruby
468
+ Name = Types::String.policy(excluded_from: ['Joe', 'Joan'])
469
+ ```
470
+
471
+ #### `:size`
472
+
473
+ Works for any value that responds to `#size` and validates that the value's size matches the argument.
474
+
475
+ ```ruby
476
+ LimitedArray = Types::Array[String].policy(size: 10)
477
+ LimitedString = Types::String.policy(size: 10)
478
+ LimitedSet = Types::Any[Set].policy(size: 10)
479
+ ```
480
+
481
+ The size is matched via `#===`, so ranges also work.
482
+
483
+ ```ruby
484
+ Password = Types::String.policy(size: 10..20)
485
+ ```
486
+
487
+ #### `:split` (strings only)
488
+
489
+ Splits string values by a separator (default: `,`).
490
+
491
+ ```ruby
492
+ CSVLine = Types::String.split
493
+ CSVLine.parse('a,b,c') # => ['a', 'b', 'c']
494
+
495
+ # Or, with custom separator
496
+ CSVLine = Types::String.split(/\s*;\s*/)
497
+ CSVLine.parse('a;b;c') # => ['a', 'b', 'c']
498
+ ```
499
+
500
+
501
+
502
+ ### `Types::Hash`
377
503
 
378
504
  ```ruby
379
505
  Employee = Types::Hash[
@@ -466,8 +592,6 @@ Types::Hash[
466
592
  ]
467
593
  ```
468
594
 
469
-
470
-
471
595
  #### Merging hash definitions
472
596
 
473
597
  Use `Types::Hash#+` to merge two definitions. Keys in the second hash override the first one's.
@@ -478,8 +602,6 @@ Employee = Types::Hash[name: Types::String, company: Types::String]
478
602
  StaffMember = User + Employee # Hash[:name, :age, :company]
479
603
  ```
480
604
 
481
-
482
-
483
605
  #### Hash intersections
484
606
 
485
607
  Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
@@ -488,8 +610,6 @@ Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
488
610
  intersection = User & Employee # Hash[:name]
489
611
  ```
490
612
 
491
-
492
-
493
613
  #### `Types::Hash#tagged_by`
494
614
 
495
615
  Use `#tagged_by` to resolve what definition to use based on the value of a common key.
@@ -509,8 +629,6 @@ Events = Types::Hash.tagged_by(
509
629
  Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
510
630
  ```
511
631
 
512
-
513
-
514
632
  #### `Types::Hash#inclusive`
515
633
 
516
634
  Use `#inclusive` to preserve input keys not defined in the hash schema.
@@ -551,8 +669,6 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
551
669
  User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
552
670
  ```
553
671
 
554
-
555
-
556
672
  ### Hash maps
557
673
 
558
674
  You can also use Hash syntax to define a hash map with specific types for all keys and values:
@@ -747,57 +863,6 @@ TODO
747
863
 
748
864
  TODO
749
865
 
750
- ## Composing types with `#>>` ("And")
751
-
752
- ```ruby
753
- Email = Types::String.match(/@/)
754
- Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") }
755
-
756
- Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
757
- ```
758
-
759
-
760
- ## Disjunction with `#|` ("Or")
761
-
762
- ```ruby
763
- StringOrInt = Types::String | Types::Integer
764
- StringOrInt.parse('hello') # "hello"
765
- StringOrInt.parse(10) # 10
766
- StringOrInt.parse({}) # raises Plumb::TypeError
767
- ```
768
-
769
- Custom default value logic for non-emails
770
-
771
- ```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"
775
- ```
776
-
777
- ## Composing with `#>>` and `#|`
778
-
779
- ```ruby
780
- require 'money'
781
-
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)
792
- end
793
-
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)
797
- ```
798
-
799
-
800
-
801
866
  ### Recursive types
802
867
 
803
868
  You can use a proc to defer evaluation of recursive definitions.
@@ -831,10 +896,6 @@ LinkedList = Types::Hash[
831
896
 
832
897
 
833
898
 
834
- ### Type-specific Rules
835
-
836
- TODO
837
-
838
899
  ### Custom types
839
900
 
840
901
  Compose procs or lambdas directly
@@ -859,8 +920,99 @@ end
859
920
  MyType = Types::String >> Greeting.new('Hola')
860
921
  ```
861
922
 
862
- You can return `result.invalid(errors: "this is invalid")` to halt processing.
923
+ ### Custom policies
924
+
925
+ `Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
926
+
927
+ This example defines a `:default_if_nil` policy that returns a default if the value is `nil`.
928
+
929
+ ```ruby
930
+ Plumb.policy :default_if_nil do |type, default_value|
931
+ type | (Types::Nil >> Types::Static[default_value])
932
+ end
933
+ ```
934
+
935
+ It can be used for any of your own types.
936
+
937
+ ```ruby
938
+ StringWithDefault = Types::String.policy(default_if_nil: 'nothing here')
939
+ StringWithDefault.parse('hello') # 'hello'
940
+ StringWithDefault.parse(nil) # 'nothing here'
941
+ ```
942
+
943
+ The `#policy` helper supports applying multiply policies.
944
+
945
+ ```ruby
946
+ Types::String.policy(default_if_nil: 'nothing here', size: (10..20))
947
+ ```
948
+
949
+
950
+
951
+ #### Policies as helper methods
952
+
953
+ Use the `helper: true` option to register the policy as a method you can call on types directly.
954
+
955
+ ```ruby
956
+ Plumb.policy :default_if_nil, helper: true do |type, default_value|
957
+ type | (Types::Nil >> Types::Static[default_value])
958
+ end
959
+
960
+ # Now use #default_if_nil directly
961
+ StringWithDefault = Types::String.default_if_nil('nothing here')
962
+ ```
963
+
964
+ 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
+
966
+ #### Type-specific policies
967
+
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.
969
+
970
+ ```ruby
971
+ Plumb.policy :multiply_by, for_type: Integer, helper: true do |type, factor|
972
+ type.invoke(:*, factor)
973
+ end
974
+
975
+ Doubled = Types::Integer.multiply_by(2)
976
+ Doubled.parse(2) # 4
977
+
978
+ # Tryin to apply this policy to a non Integer will raise an exception
979
+ DoubledString = Types::String.multiply_by(2) # raises error
980
+ ```
981
+
982
+ #### Interface-specific policies
983
+
984
+ `for_type`also supports a Symbol for a method name, so that the policy can be applied to any types that support that method.
985
+
986
+ This example allows the `multiply_by` policy to work with any type that can be multiplied (by supporting the `:*` method).
987
+
988
+ ```ruby
989
+ Plumb.policy :multiply_by, for_type: :*, helper: true do |type, factor|
990
+ type.invoke(:*, factor)
991
+ end
992
+
993
+ # Now it works with anything that can be multiplied.
994
+ DoubledNumeric = Types::Numeric.multiply_by(2)
995
+ DoubledMoney = Types::Any[Money].multiply_by(2)
996
+ ```
997
+
998
+ #### Self-contained policy modules
999
+
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.
863
1001
 
1002
+ ```ruby
1003
+ module SplitPolicy
1004
+ DEFAULT_SEPARATOR = /\s*,\s*/
1005
+
1006
+ def self.call(type, separator = DEFAULT_SEPARATOR)
1007
+ type.transform(Array) { |v| v.split(separator) }
1008
+ end
1009
+
1010
+ def self.for_type = ::String
1011
+ def self.helper = false
1012
+ end
1013
+
1014
+ Plumb.policy :split, SplitPolicy
1015
+ ```
864
1016
 
865
1017
  ### JSON Schema
866
1018
 
@@ -883,6 +1035,22 @@ json_schema = Plumb::JSONSchemaVisitor.call(User)
883
1035
  }
884
1036
  ```
885
1037
 
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:
1039
+
1040
+ ```ruby
1041
+ Plumb::JSONSchemaVisitor.on(:not) do |node, props|
1042
+ props.merge('not' => visit(node.step))
1043
+ end
1044
+
1045
+ # Example
1046
+ type = Types::Decimal.not
1047
+ schema = Plumb::JSONSchemaVisitor.visit(type) # { 'not' => { 'type' => 'number' } }
1048
+ ```
1049
+
1050
+ #### JSON Schema handlers for custom policies
1051
+
1052
+ TODO. See `Plumb::JSONSchemaVisitor`.
1053
+
886
1054
 
887
1055
 
888
1056
  ## Development
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'plumb'
5
+ require 'debug'
6
+
7
+ # Types and pipelines for defining and parsing ENV configuration
8
+ # Run with `bundle exec ruby examples/env_config.rb`
9
+ #
10
+ # Given an ENV with variables to configure one of three types of network/IO clients,
11
+ # parse, validate and coerce the configuration into the appropriate client object.
12
+ # ENV vars are expected to be prefixed with `FILE_UPLOAD_`, followed by the client type.
13
+ # See example usage at the bottom of this file.
14
+ module Types
15
+ include Plumb::Types
16
+
17
+ # Define a custom policy to extract a string using a regular expression.
18
+ # Policies are factories for custom type compositions.
19
+ #
20
+ # Usage:
21
+ # type = Types::String.extract(/^FOO_(\w+)$/).invoke(:[], 1)
22
+ # type.parse('FOO_BAR') # => 'BAR'
23
+ #
24
+ Plumb.policy :extract, for_type: ::String, helper: true do |type, regex|
25
+ type >> lambda do |result|
26
+ match = result.value.match(regex)
27
+ return result.invalid(errors: "does not match #{regex.source}") if match.nil?
28
+ return result.invalid(errors: 'no captures') if match.captures.none?
29
+
30
+ result.valid(match)
31
+ end
32
+ end
33
+
34
+ # A dummy S3 client
35
+ S3Client = Data.define(:bucket, :region)
36
+
37
+ # A dummy SFTP client
38
+ SFTPClient = Data.define(:host, :username, :password)
39
+
40
+ # Map these fields to an S3 client
41
+ S3Config = Types::Hash[
42
+ transport: 's3',
43
+ bucket: String.present,
44
+ region: String.options(%w[us-west-1 us-west-2 us-east-1])
45
+ ].invoke(:except, :transport).build(S3Client) { |h| S3Client.new(**h) }
46
+
47
+ # Map these fields to an SFTP client
48
+ SFTPConfig = Types::Hash[
49
+ transport: 'sftp',
50
+ host: String.present,
51
+ username: String.present,
52
+ password: String.present,
53
+ ].invoke(:except, :transport).build(SFTPClient) { |h| SFTPClient.new(**h) }
54
+
55
+ # Map these fields to a File client
56
+ FileConfig = Types::Hash[
57
+ transport: 'file',
58
+ path: String.present,
59
+ ].invoke(:[], :path).build(::File)
60
+
61
+ # Take a string such as 'FILE_UPLOAD_BUCKET', extract the `BUCKET` bit,
62
+ # downcase and symbolize it.
63
+ FileUploadKey = String.extract(/^FILE_UPLOAD_(\w+)$/).invoke(:[], 1).invoke(%i[downcase to_sym])
64
+
65
+ # Filter a Hash (or ENV) to keys that match the FILE_UPLOAD_* pattern
66
+ FileUploadHash = Types::Hash[FileUploadKey, Any].filtered
67
+
68
+ # Pipeline syntax to put the program together
69
+ FileUploadClientFromENV = Any.pipeline do |pl|
70
+ # 1. Accept any Hash-like object (e.g. ENV)
71
+ pl.step Types::Interface[:[], :key?, :each, :to_h]
72
+
73
+ # 2. Transform it to a Hash
74
+ pl.step Any.transform(::Hash, &:to_h)
75
+
76
+ # 3. Filter keys with FILE_UPLOAD_* prefix
77
+ pl.step FileUploadHash
78
+
79
+ # 4. Parse the configuration for a particular client object
80
+ pl.step(S3Config | SFTPConfig | FileConfig)
81
+ end
82
+
83
+ # Ex.
84
+ # client = FileUploadClientFromENV.parse(ENV) # SFTP, S3 or File client
85
+
86
+ # The above is the same as:
87
+ #
88
+ # FileUploadClientFromENV = Types::Interface[:[], :key?, :each, :to_h] \
89
+ # .transform(::Hash, &:to_h) >> \
90
+ # Types::Hash[FileUploadKey, Any].filtered >> \
91
+ # (S3Config | SFTPConfig | FileConfig)
92
+ end
93
+
94
+ # Simulated ENV hashes. Just use ::ENV for the real thing.
95
+ ENV_S3 = {
96
+ 'FILE_UPLOAD_TRANSPORT' => 's3',
97
+ 'FILE_UPLOAD_BUCKET' => 'my-bucket',
98
+ 'FILE_UPLOAD_REGION' => 'us-west-2',
99
+ 'SOMETHING_ELSE' => 'ignored'
100
+ }.freeze
101
+ # => S3Client.new(bucket: 'my-bucket', region: 'us-west-2')
102
+
103
+ ENV_SFTP = {
104
+ 'FILE_UPLOAD_TRANSPORT' => 'sftp',
105
+ 'FILE_UPLOAD_HOST' => 'sftp.example.com',
106
+ 'FILE_UPLOAD_USERNAME' => 'username',
107
+ 'FILE_UPLOAD_PASSWORD' => 'password',
108
+ 'SOMETHING_ELSE' => 'ignored'
109
+ }.freeze
110
+ # => SFTPClient.new(host: 'sftp.example.com', username: 'username', password: 'password')
111
+
112
+ ENV_FILE = {
113
+ 'FILE_UPLOAD_TRANSPORT' => 'file',
114
+ 'FILE_UPLOAD_PATH' => File.join('examples', 'programmers.csv')
115
+ }.freeze
116
+
117
+ p Types::FileUploadClientFromENV.parse(ENV_S3) # #<data Types::S3Client bucket="my-bucket", region="us-west-2">
118
+ p Types::FileUploadClientFromENV.parse(ENV_SFTP) # #<data Types::SFTPClient host="sftp.example.com", username="username", password="password">
119
+ p Types::FileUploadClientFromENV.parse(ENV_FILE) # #<File path="examples/programmers.csv">
120
+
121
+ # Or with invalid or missing configuration
122
+ # p Types::FileUploadClientFromENV.parse({}) # raises error
data/examples/weekdays.rb CHANGED
@@ -41,7 +41,7 @@ module Types
41
41
  # Ex. [1, 2, 3, 4, 5, 6, 7], [1, 2, 4], ['monday', 'tuesday', 'wednesday', 7]
42
42
  # Turn day names into numbers, and sort the array.
43
43
  Week = Array[DayNameOrNumber]
44
- .rule(size: 1..7)
44
+ .policy(size: 1..7)
45
45
  .check('repeated days') { |days| days.uniq.size == days.size }
46
46
  .transform(::Array, &:sort)
47
47
  end
@@ -30,9 +30,7 @@ module Plumb
30
30
  case args
31
31
  in [::Hash => hash]
32
32
  self.class.new(schema: _schema.merge(wrap_keys_and_values(hash)), inclusive: @inclusive)
33
- in [Steppable => key_type, value_type]
34
- HashMap.new(key_type, Steppable.wrap(value_type))
35
- in [Class => key_type, value_type]
33
+ in [key_type, value_type]
36
34
  HashMap.new(Steppable.wrap(key_type), Steppable.wrap(value_type))
37
35
  else
38
36
  raise ::ArgumentError, "unexpected value to Types::Hash#schema #{args.inspect}"