plumb 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +2 -0
- data/.rubocop.yml +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +628 -0
- data/Rakefile +8 -0
- data/lib/plumb/and.rb +25 -0
- data/lib/plumb/any_class.rb +19 -0
- data/lib/plumb/array_class.rb +87 -0
- data/lib/plumb/build.rb +18 -0
- data/lib/plumb/deferred.rb +31 -0
- data/lib/plumb/hash_class.rb +126 -0
- data/lib/plumb/hash_map.rb +35 -0
- data/lib/plumb/interface_class.rb +35 -0
- data/lib/plumb/json_schema_visitor.rb +222 -0
- data/lib/plumb/key.rb +41 -0
- data/lib/plumb/match_class.rb +39 -0
- data/lib/plumb/metadata.rb +15 -0
- data/lib/plumb/metadata_visitor.rb +116 -0
- data/lib/plumb/not.rb +26 -0
- data/lib/plumb/or.rb +29 -0
- data/lib/plumb/pipeline.rb +73 -0
- data/lib/plumb/result.rb +64 -0
- data/lib/plumb/rules.rb +103 -0
- data/lib/plumb/schema.rb +193 -0
- data/lib/plumb/static_class.rb +30 -0
- data/lib/plumb/step.rb +21 -0
- data/lib/plumb/steppable.rb +242 -0
- data/lib/plumb/tagged_hash.rb +37 -0
- data/lib/plumb/transform.rb +20 -0
- data/lib/plumb/tuple_class.rb +42 -0
- data/lib/plumb/type_registry.rb +37 -0
- data/lib/plumb/types.rb +140 -0
- data/lib/plumb/value_class.rb +23 -0
- data/lib/plumb/version.rb +5 -0
- data/lib/plumb/visitor_handlers.rb +34 -0
- data/lib/plumb.rb +25 -0
- metadata +107 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 573f2aeb395f26fdc392bf7741d9874fb0318fabc4b8af6f1d1f7a8991f50100
|
4
|
+
data.tar.gz: 464c7aaa1b6dcbae0195b2bf51103438f3cd8a7f91593af0640a55f6f568e011
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 76fa4ec0bbed8a7e2c33537c7079ce47b7f4cdc693c2d61b4218980b36e17d2f57cd2cc80229c3bd91f2024dbcd706c4fd8846a3bc7dd7a730146f2417a6e882
|
7
|
+
data.tar.gz: '03048bc44d4d3f0d9fca72d312cca9b679a2ff61e1026593c63fcbe727f550867862f43cecbdf3de93de756a5b577ff9e5f004c2f8c11e42985f4c23f9b0963d'
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Ismael Celis
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,628 @@
|
|
1
|
+
# Plumb
|
2
|
+
|
3
|
+
Composable data validation and coercion in Ruby. WiP.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
TODO
|
8
|
+
|
9
|
+
## Usage
|
10
|
+
|
11
|
+
### Include base types
|
12
|
+
|
13
|
+
Include base types in your own namespace:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
module Types
|
17
|
+
# Include Plumb base types, such as String, Integer, Boolean
|
18
|
+
include Plumb::Types
|
19
|
+
|
20
|
+
# Define your own types
|
21
|
+
Email = String[/&/]
|
22
|
+
end
|
23
|
+
|
24
|
+
# Use them
|
25
|
+
result = Types::String.resolve("hello")
|
26
|
+
result.valid? # true
|
27
|
+
result.errors # nil
|
28
|
+
|
29
|
+
result = Types::Email.resolve("foo")
|
30
|
+
result.valid? # false
|
31
|
+
result.errors # ""
|
32
|
+
```
|
33
|
+
|
34
|
+
|
35
|
+
|
36
|
+
### `#resolve(value) => Result`
|
37
|
+
|
38
|
+
`#resolve` takes an input value and returns a `Result::Valid` or `Result::Invalid`
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
result = Types::Integer.resolve(10)
|
42
|
+
result.valid? # true
|
43
|
+
result.value # 10
|
44
|
+
|
45
|
+
result = Types::Integer.resolve('10')
|
46
|
+
result.valid? # false
|
47
|
+
result.value # '10'
|
48
|
+
result.errors # 'must be an Integer'
|
49
|
+
```
|
50
|
+
|
51
|
+
|
52
|
+
|
53
|
+
### `#parse(value) => value`
|
54
|
+
|
55
|
+
`#parse` takes an input value and returns the parsed/coerced value if successful. or it raises an exception if failed.
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
Types::Integer.parse(10) # 10
|
59
|
+
Types::Integer.parse('10') # raises Plumb::TypeError
|
60
|
+
```
|
61
|
+
|
62
|
+
|
63
|
+
|
64
|
+
## Built-in types
|
65
|
+
|
66
|
+
* `Types::Value`
|
67
|
+
* `Types::Array`
|
68
|
+
* `Types::True`
|
69
|
+
* `Types::Symbol`
|
70
|
+
* `Types::Boolean`
|
71
|
+
* `Types::Interface`
|
72
|
+
* `Types::False`
|
73
|
+
* `Types::Tuple`
|
74
|
+
* `Types::Split`
|
75
|
+
* `Types::Blank`
|
76
|
+
* `Types::Any`
|
77
|
+
* `Types::Static`
|
78
|
+
* `Types::Undefined`
|
79
|
+
* `Types::Nil`
|
80
|
+
* `Types::Present`
|
81
|
+
* `Types::Integer`
|
82
|
+
* `Types::Numeric`
|
83
|
+
* `Types::String`
|
84
|
+
* `Types::Hash`
|
85
|
+
* `Types::Lax::Integer`
|
86
|
+
* `Types::Lax::String`
|
87
|
+
* `Types::Lax::Symbol`
|
88
|
+
* `Types::Forms::Boolean`
|
89
|
+
* `Types::Forms::Nil`
|
90
|
+
* `Types::Forms::True`
|
91
|
+
* `Types::Forms::False`
|
92
|
+
|
93
|
+
|
94
|
+
|
95
|
+
### `#present`
|
96
|
+
|
97
|
+
Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`)
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
Types::String.present.resolve('') # Failure with errors
|
101
|
+
Types::Array[Types::String].resolve([]) # Failure with errors
|
102
|
+
```
|
103
|
+
|
104
|
+
### `#nullable`
|
105
|
+
|
106
|
+
Allow `nil` values.
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
nullable_str = Types::String.nullable
|
110
|
+
nullable_srt.parse(nil) # nil
|
111
|
+
nullable_str.parse('hello') # 'hello'
|
112
|
+
nullable_str.parse(10) # TypeError
|
113
|
+
```
|
114
|
+
|
115
|
+
Note that this is syntax sugar for
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
nullable_str = Types::String | Types::Nil
|
119
|
+
```
|
120
|
+
|
121
|
+
|
122
|
+
|
123
|
+
### `#not`
|
124
|
+
|
125
|
+
Negates a type.
|
126
|
+
```ruby
|
127
|
+
NotEmail = Types::Email.not
|
128
|
+
|
129
|
+
NotEmail.parse('hello') # "hello"
|
130
|
+
NotEmail.parse('hello@server.com') # error
|
131
|
+
```
|
132
|
+
|
133
|
+
### `#options`
|
134
|
+
|
135
|
+
Sets allowed options for value.
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
type = Types::String.options(['a', 'b', 'c'])
|
139
|
+
type.resolve('a') # Valid
|
140
|
+
type.resolve('x') # Failure
|
141
|
+
```
|
142
|
+
|
143
|
+
For arrays, it checks that all elements in array are included in options.
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
type = Types::Array.options(['a', 'b'])
|
147
|
+
type.resolve(['a', 'a', 'b']) # Valid
|
148
|
+
type.resolve(['a', 'x', 'b']) # Failure
|
149
|
+
```
|
150
|
+
|
151
|
+
|
152
|
+
|
153
|
+
### `#transform`
|
154
|
+
|
155
|
+
Transform value. Requires specifying the resulting type of the value after transformation.
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
StringToInt = Types::String.transform(Integer) { |value| value.to_i }
|
159
|
+
# Same as
|
160
|
+
StringToInt = Types::String.transform(Integer, &:to_i)
|
161
|
+
|
162
|
+
StringToInteger.parse('10') # => 10
|
163
|
+
```
|
164
|
+
|
165
|
+
|
166
|
+
|
167
|
+
### `#default`
|
168
|
+
|
169
|
+
Default value when no value given (ie. when key is missing in Hash payloads. See `Types::Hash` below).
|
170
|
+
|
171
|
+
```ruby
|
172
|
+
str = Types::String.default('nope'.freeze)
|
173
|
+
str.parse() # 'nope'
|
174
|
+
str.parse('yup') # 'yup'
|
175
|
+
```
|
176
|
+
|
177
|
+
Note that this is syntax sugar for:
|
178
|
+
|
179
|
+
```ruby
|
180
|
+
# A String, or if it's Undefined pipe to a static string value.
|
181
|
+
str = Types::String | (Types::Undefined >> 'nope'.freeze)
|
182
|
+
```
|
183
|
+
|
184
|
+
Meaning that you can compose your own semantics for a "default" value.
|
185
|
+
|
186
|
+
Example when you want to apply a default when the given value is `nil`.
|
187
|
+
|
188
|
+
```ruby
|
189
|
+
str = Types::String | (Types::Nil >> 'nope'.freeze)
|
190
|
+
|
191
|
+
str.parse(nil) # 'nope'
|
192
|
+
str.parse('yup') # 'yup'
|
193
|
+
```
|
194
|
+
|
195
|
+
Same if you want to apply a default to several cases.
|
196
|
+
|
197
|
+
```ruby
|
198
|
+
str = Types::String | ((Types::Nil | Types::Undefined) >> 'nope'.freeze)
|
199
|
+
```
|
200
|
+
|
201
|
+
|
202
|
+
|
203
|
+
### `#match` and `#[]`
|
204
|
+
|
205
|
+
Checks the value against a regular expression (or anything that responds to `#===`).
|
206
|
+
|
207
|
+
```ruby
|
208
|
+
email = Types::String.match(/@/)
|
209
|
+
# Same as
|
210
|
+
email = Types::String[/@/]
|
211
|
+
email.parse('hello') # fails
|
212
|
+
email.parse('hello@server.com') # 'hello@server.com'
|
213
|
+
```
|
214
|
+
|
215
|
+
It can be combined with other methods. For example to cast strings as integers, but only if they _look_ like integers.
|
216
|
+
|
217
|
+
```ruby
|
218
|
+
StringToInt = Types::String[/^\d+$/].transform(::Integer, &:to_i)
|
219
|
+
|
220
|
+
StringToInt.parse('100') # => 100
|
221
|
+
StringToInt.parse('100lol') # fails
|
222
|
+
```
|
223
|
+
|
224
|
+
It can be used with other `#===` interfaces.
|
225
|
+
|
226
|
+
```ruby
|
227
|
+
AgeBracket = Types::Integer[21..45]
|
228
|
+
|
229
|
+
AgeBracket.parse(22) # 22
|
230
|
+
AgeBracket.parse(20) # fails
|
231
|
+
|
232
|
+
# With literal values
|
233
|
+
Twenty = Types::Integer[20]
|
234
|
+
Twenty.parse(20) # 20
|
235
|
+
Twenty.parse(21) # type error
|
236
|
+
```
|
237
|
+
|
238
|
+
|
239
|
+
|
240
|
+
### `#build`
|
241
|
+
|
242
|
+
Build a custom object or class.
|
243
|
+
|
244
|
+
```ruby
|
245
|
+
User = Data.define(:name)
|
246
|
+
UserType = Types::String.build(User)
|
247
|
+
|
248
|
+
UserType.parse('Joe') # #<data User name="Joe">
|
249
|
+
```
|
250
|
+
|
251
|
+
It takes an argument for a custom factory method on the object constructor.
|
252
|
+
|
253
|
+
```ruby
|
254
|
+
class User
|
255
|
+
def self.create(attrs)
|
256
|
+
new(attrs)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
UserType = Types::String.build(User, :create)
|
261
|
+
```
|
262
|
+
|
263
|
+
You can also pass a block
|
264
|
+
|
265
|
+
```ruby
|
266
|
+
UserType = Types::String.build(User) { |name| User.new(name) }
|
267
|
+
```
|
268
|
+
|
269
|
+
Note that this case is identical to `#transform` with a block.
|
270
|
+
|
271
|
+
```ruby
|
272
|
+
UserType = Types::String.transform(User) { |name| User.new(name) }
|
273
|
+
```
|
274
|
+
|
275
|
+
|
276
|
+
|
277
|
+
### `#check`
|
278
|
+
|
279
|
+
Pass the value through an arbitrary validation
|
280
|
+
|
281
|
+
```ruby
|
282
|
+
type = Types::String.check('must start with "Role:"') { |value| value.start_with?('Role:') }
|
283
|
+
type.parse('Role: Manager') # 'Role: Manager'
|
284
|
+
type.parse('Manager') # fails
|
285
|
+
```
|
286
|
+
|
287
|
+
|
288
|
+
|
289
|
+
### `#value`
|
290
|
+
|
291
|
+
Constrain a type to a specific value. Compares with `#==`
|
292
|
+
|
293
|
+
```ruby
|
294
|
+
hello = Types::String.value('hello')
|
295
|
+
hello.parse('hello') # 'hello'
|
296
|
+
hello.parse('bye') # fails
|
297
|
+
hello.parse(10) # fails 'not a string'
|
298
|
+
```
|
299
|
+
|
300
|
+
All scalar types support this:
|
301
|
+
|
302
|
+
```ruby
|
303
|
+
ten = Types::Integer.value(10)
|
304
|
+
```
|
305
|
+
|
306
|
+
|
307
|
+
|
308
|
+
### `#meta` and `#metadata`
|
309
|
+
|
310
|
+
Add metadata to a type
|
311
|
+
|
312
|
+
```ruby
|
313
|
+
type = Types::String.meta(description: 'A long text')
|
314
|
+
type.metadata[:description] # 'A long text'
|
315
|
+
```
|
316
|
+
|
317
|
+
`#metadata` combines keys from type compositions.
|
318
|
+
|
319
|
+
```ruby
|
320
|
+
type = Types::String.meta(description: 'A long text') >> Types::String.match(/@/).meta(note: 'An email address')
|
321
|
+
type.metadata[:description] # 'A long text'
|
322
|
+
type.metadata[:note] # 'An email address'
|
323
|
+
```
|
324
|
+
|
325
|
+
`#metadata` also computes the target type.
|
326
|
+
|
327
|
+
```ruby
|
328
|
+
Types::String.metadata[:type] # String
|
329
|
+
Types::String.transform(Integer, &:to_i).metadata[:type] # Integer
|
330
|
+
# Multiple target types for unions
|
331
|
+
(Types::String | Types::Integer).metadata[:type] # [String, Integer]
|
332
|
+
```
|
333
|
+
|
334
|
+
TODO: document custom visitors.
|
335
|
+
|
336
|
+
## `Types::Hash`
|
337
|
+
|
338
|
+
```ruby
|
339
|
+
Employee = Types::Hash[
|
340
|
+
name: Types::String.present,
|
341
|
+
age?: Types::Lax::Integer,
|
342
|
+
role: Types::String.options(%w[product accounts sales]).default('product')
|
343
|
+
]
|
344
|
+
|
345
|
+
Company = Types::Hash[
|
346
|
+
name: Types::String.present,
|
347
|
+
employees: Types::Array[Employee]
|
348
|
+
]
|
349
|
+
|
350
|
+
result = Company.resolve(
|
351
|
+
name: 'ACME',
|
352
|
+
employees: [
|
353
|
+
{ name: 'Joe', age: 40, role: 'product' },
|
354
|
+
{ name: 'Joan', age: 38, role: 'engineer' }
|
355
|
+
]
|
356
|
+
)
|
357
|
+
|
358
|
+
result.valid? # true
|
359
|
+
|
360
|
+
result = Company.resolve(
|
361
|
+
name: 'ACME',
|
362
|
+
employees: [{ name: 'Joe' }]
|
363
|
+
)
|
364
|
+
|
365
|
+
result.valid? # false
|
366
|
+
result.errors[:employees][0][:age] # ["must be a Numeric"]
|
367
|
+
```
|
368
|
+
|
369
|
+
|
370
|
+
|
371
|
+
#### Merging hash definitions
|
372
|
+
|
373
|
+
Use `Types::Hash#+` to merge two definitions. Keys in the second hash override the first one's.
|
374
|
+
|
375
|
+
```ruby
|
376
|
+
User = Types::Hash[name: Types::String, age: Types::Integer]
|
377
|
+
Employee = Types::Hash[name: Types::String, company: Types::String]
|
378
|
+
StaffMember = User + Employee # Hash[:name, :age, :company]
|
379
|
+
```
|
380
|
+
|
381
|
+
|
382
|
+
|
383
|
+
#### Hash intersections
|
384
|
+
|
385
|
+
Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
|
386
|
+
|
387
|
+
```ruby
|
388
|
+
intersection = User & Employee # Hash[:name]
|
389
|
+
```
|
390
|
+
|
391
|
+
|
392
|
+
|
393
|
+
#### `Types::Hash#tagged_by`
|
394
|
+
|
395
|
+
Use `#tagged_by` to resolve what definition to use based on the value of a common key.
|
396
|
+
|
397
|
+
```ruby
|
398
|
+
NameUpdatedEvent = Types::Hash[type: 'name_updated', name: Types::String]
|
399
|
+
AgeUpdatedEvent = Types::Hash[type: 'age_updated', age: Types::Integer]
|
400
|
+
|
401
|
+
Events = Types::Hash.tagged_by(
|
402
|
+
:type,
|
403
|
+
NameUpdatedEvent,
|
404
|
+
AgeUpdatedEvent
|
405
|
+
)
|
406
|
+
|
407
|
+
Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
|
408
|
+
```
|
409
|
+
|
410
|
+
|
411
|
+
|
412
|
+
### Hash maps
|
413
|
+
|
414
|
+
You can also use Hash syntax to define a hash map with specific types for all keys and values:
|
415
|
+
|
416
|
+
```ruby
|
417
|
+
currencies = Types::Hash[Types::Symbol, Types::String]
|
418
|
+
|
419
|
+
currencies.parse(usd: 'USD', gbp: 'GBP') # Ok
|
420
|
+
currencies.parse('usd' => 'USD') # Error. Keys must be Symbols
|
421
|
+
```
|
422
|
+
|
423
|
+
|
424
|
+
|
425
|
+
### `Types::Array`
|
426
|
+
|
427
|
+
```ruby
|
428
|
+
names = Types::Array[Types::String.present]
|
429
|
+
names_or_ages = Types::Array[Types::String.present | Types::Integer[21..]]
|
430
|
+
```
|
431
|
+
|
432
|
+
#### Concurrent arrays
|
433
|
+
|
434
|
+
Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
|
435
|
+
|
436
|
+
```ruby
|
437
|
+
ImageDownload = Types::URL >> ->(result) { HTTP.get(result.value) }
|
438
|
+
Images = Types::Array[ImageDownload].concurrent
|
439
|
+
|
440
|
+
# Images are downloaded concurrently and returned in order.
|
441
|
+
Images.parse(['https://images.com/1.png', 'https://images.com/2.png'])
|
442
|
+
```
|
443
|
+
|
444
|
+
TODO: pluggable concurrently engines (Async?)
|
445
|
+
|
446
|
+
### `Types::Tuple`
|
447
|
+
|
448
|
+
```ruby
|
449
|
+
Status = Types::Symbol.options(%i[ok error])
|
450
|
+
Result = Types::Tuple[Status, Types::String]
|
451
|
+
|
452
|
+
Result.parse([:ok, 'all good']) # [:ok, 'all good']
|
453
|
+
Result.parse([:ok, 'all bad', 'nope']) # type error
|
454
|
+
```
|
455
|
+
|
456
|
+
Note that literal values can be used too.
|
457
|
+
|
458
|
+
```ruby
|
459
|
+
Ok = Types::Tuple[:ok, nil]
|
460
|
+
Error = Types::Tuple[:error, Types::String.present]
|
461
|
+
Status = Ok | Error
|
462
|
+
```
|
463
|
+
|
464
|
+
|
465
|
+
|
466
|
+
### Plumb::Schema
|
467
|
+
|
468
|
+
TODO
|
469
|
+
|
470
|
+
### Plumb::Pipeline
|
471
|
+
|
472
|
+
TODO
|
473
|
+
|
474
|
+
### Plumb::Struct
|
475
|
+
|
476
|
+
TODO
|
477
|
+
|
478
|
+
## Composing types with `#>>` ("And")
|
479
|
+
|
480
|
+
```ruby
|
481
|
+
Email = Types::String.match(/@/)
|
482
|
+
Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") }
|
483
|
+
|
484
|
+
Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
485
|
+
```
|
486
|
+
|
487
|
+
|
488
|
+
## Disjunction with `#|` ("Or")
|
489
|
+
|
490
|
+
```ruby
|
491
|
+
StringOrInt = Types::String | Types::Integer
|
492
|
+
StringOrInt.parse('hello') # "hello"
|
493
|
+
StringOrInt.parse(10) # 10
|
494
|
+
StringOrInt.parse({}) # raises Plumb::TypeError
|
495
|
+
```
|
496
|
+
|
497
|
+
Custom default value logic for non-emails
|
498
|
+
|
499
|
+
```ruby
|
500
|
+
EmailOrDefault = Greeting | Types::Static['no email']
|
501
|
+
EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com"
|
502
|
+
EmailOrDefault.parse('nope') # "no email"
|
503
|
+
```
|
504
|
+
|
505
|
+
## Composing with `#>>` and `#|`
|
506
|
+
|
507
|
+
```ruby
|
508
|
+
require 'money'
|
509
|
+
|
510
|
+
module Types
|
511
|
+
include Plumb::Types
|
512
|
+
|
513
|
+
Money = Any[::Money]
|
514
|
+
IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') }
|
515
|
+
StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i)
|
516
|
+
USD = Money.check { |amount| amount.currency.code == 'UDS' }
|
517
|
+
ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') }
|
518
|
+
|
519
|
+
FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD)
|
520
|
+
end
|
521
|
+
|
522
|
+
FlexibleUSD.parse('1000') # Money(USD 10.00)
|
523
|
+
FlexibleUSD.parse(1000) # Money(USD 10.00)
|
524
|
+
FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
|
525
|
+
```
|
526
|
+
|
527
|
+
|
528
|
+
|
529
|
+
### Recursive types
|
530
|
+
|
531
|
+
You can use a proc to defer evaluation of recursive definitions.
|
532
|
+
|
533
|
+
```ruby
|
534
|
+
LinkedList = Types::Hash[
|
535
|
+
value: Types::Any,
|
536
|
+
next: Types::Nil | proc { |result| LinkedList.(result) }
|
537
|
+
]
|
538
|
+
|
539
|
+
LinkedList.parse(
|
540
|
+
value: 1,
|
541
|
+
next: {
|
542
|
+
value: 2,
|
543
|
+
next: {
|
544
|
+
value: 3,
|
545
|
+
next: nil
|
546
|
+
}
|
547
|
+
}
|
548
|
+
)
|
549
|
+
```
|
550
|
+
|
551
|
+
You can also use `#defer`
|
552
|
+
|
553
|
+
```ruby
|
554
|
+
LinkedList = Types::Hash[
|
555
|
+
value: Types::Any,
|
556
|
+
next: Types::Any.defer { LinkedList } | Types::Nil
|
557
|
+
]
|
558
|
+
```
|
559
|
+
|
560
|
+
|
561
|
+
|
562
|
+
### Type-specific Rules
|
563
|
+
|
564
|
+
TODO
|
565
|
+
|
566
|
+
### Custom types
|
567
|
+
|
568
|
+
Compose procs or lambdas directly
|
569
|
+
|
570
|
+
```ruby
|
571
|
+
Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
|
572
|
+
```
|
573
|
+
|
574
|
+
or a custom class that responds to `#call(Result::Valid) => Result::Valid | Result::Invalid`
|
575
|
+
|
576
|
+
```ruby
|
577
|
+
class Greeting
|
578
|
+
def initialize(gr = 'Hello')
|
579
|
+
@gr = gr
|
580
|
+
end
|
581
|
+
|
582
|
+
def call(result)
|
583
|
+
result.valid("#{gr} #{result.value}")
|
584
|
+
end
|
585
|
+
end
|
586
|
+
|
587
|
+
MyType = Types::String >> Greeting.new('Hola')
|
588
|
+
```
|
589
|
+
|
590
|
+
You can return `result.invalid(errors: "this is invalid")` to halt processing.
|
591
|
+
|
592
|
+
|
593
|
+
### JSON Schema
|
594
|
+
|
595
|
+
```ruby
|
596
|
+
User = Types::Hash[
|
597
|
+
name: Types::String,
|
598
|
+
age: Types::Integer[21..]
|
599
|
+
]
|
600
|
+
|
601
|
+
json_schema = Plumb::JSONSchemaVisitor.call(User)
|
602
|
+
|
603
|
+
{
|
604
|
+
'$schema'=>'https://json-schema.org/draft-08/schema#',
|
605
|
+
'type' => 'object',
|
606
|
+
'properties' => {
|
607
|
+
'name' => {'type' => 'string'},
|
608
|
+
'age' => {'type' =>'integer', 'minimum' => 21}
|
609
|
+
},
|
610
|
+
'required' =>['name', 'age']
|
611
|
+
}
|
612
|
+
```
|
613
|
+
|
614
|
+
|
615
|
+
|
616
|
+
## Development
|
617
|
+
|
618
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
619
|
+
|
620
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
621
|
+
|
622
|
+
## Contributing
|
623
|
+
|
624
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ismasan/plumb.
|
625
|
+
|
626
|
+
## License
|
627
|
+
|
628
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/lib/plumb/and.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'plumb/steppable'
|
4
|
+
|
5
|
+
module Plumb
|
6
|
+
class And
|
7
|
+
include Steppable
|
8
|
+
|
9
|
+
attr_reader :left, :right
|
10
|
+
|
11
|
+
def initialize(left, right)
|
12
|
+
@left = left
|
13
|
+
@right = right
|
14
|
+
freeze
|
15
|
+
end
|
16
|
+
|
17
|
+
private def _inspect
|
18
|
+
%((#{@left.inspect} >> #{@right.inspect}))
|
19
|
+
end
|
20
|
+
|
21
|
+
def call(result)
|
22
|
+
result.map(@left).map(@right)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'plumb/steppable'
|
4
|
+
|
5
|
+
module Plumb
|
6
|
+
class AnyClass
|
7
|
+
include Steppable
|
8
|
+
|
9
|
+
def |(other) = Steppable.wrap(other)
|
10
|
+
def >>(other) = Steppable.wrap(other)
|
11
|
+
|
12
|
+
# Any.default(value) must trigger default when value is Undefined
|
13
|
+
def default(...)
|
14
|
+
Types::Undefined.not.default(...)
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(result) = result
|
18
|
+
end
|
19
|
+
end
|