plumb 0.0.1

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