plumb 0.0.1

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 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
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.rubocop.yml ADDED
@@ -0,0 +1,4 @@
1
+ Style/CaseEquality:
2
+ Enabled: false
3
+ Style/LambdaCall:
4
+ Enabled: false
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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
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