plumb 0.0.2 → 0.0.3

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