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 +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
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'concurrent'
|
4
|
+
require 'plumb/steppable'
|
5
|
+
require 'plumb/result'
|
6
|
+
require 'plumb/hash_class'
|
7
|
+
|
8
|
+
module Plumb
|
9
|
+
class ArrayClass
|
10
|
+
include Steppable
|
11
|
+
|
12
|
+
attr_reader :element_type
|
13
|
+
|
14
|
+
def initialize(element_type: Types::Any)
|
15
|
+
@element_type = case element_type
|
16
|
+
when Steppable
|
17
|
+
element_type
|
18
|
+
when ::Hash
|
19
|
+
HashClass.new(element_type)
|
20
|
+
else
|
21
|
+
raise ArgumentError,
|
22
|
+
"element_type #{element_type.inspect} must be a Steppable"
|
23
|
+
end
|
24
|
+
|
25
|
+
freeze
|
26
|
+
end
|
27
|
+
|
28
|
+
def of(element_type)
|
29
|
+
self.class.new(element_type:)
|
30
|
+
end
|
31
|
+
|
32
|
+
alias [] of
|
33
|
+
|
34
|
+
def concurrent
|
35
|
+
ConcurrentArrayClass.new(element_type:)
|
36
|
+
end
|
37
|
+
|
38
|
+
private def _inspect
|
39
|
+
%(#{name}[#{element_type}])
|
40
|
+
end
|
41
|
+
|
42
|
+
def call(result)
|
43
|
+
return result.invalid(errors: 'is not an Array') unless result.value.is_a?(::Enumerable)
|
44
|
+
|
45
|
+
values, errors = map_array_elements(result.value)
|
46
|
+
return result.valid(values) unless errors.any?
|
47
|
+
|
48
|
+
result.invalid(errors:)
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def map_array_elements(list)
|
54
|
+
# Reuse the same result object for each element
|
55
|
+
# to decrease object allocation.
|
56
|
+
# Steps might return the same result instance, so we map the values directly
|
57
|
+
# separate from the errors.
|
58
|
+
element_result = BLANK_RESULT.dup
|
59
|
+
errors = {}
|
60
|
+
values = list.map.with_index do |e, idx|
|
61
|
+
re = element_type.call(element_result.reset(e))
|
62
|
+
errors[idx] = re.errors unless re.valid?
|
63
|
+
re.value
|
64
|
+
end
|
65
|
+
|
66
|
+
[values, errors]
|
67
|
+
end
|
68
|
+
|
69
|
+
class ConcurrentArrayClass < self
|
70
|
+
private
|
71
|
+
|
72
|
+
def map_array_elements(list)
|
73
|
+
errors = {}
|
74
|
+
|
75
|
+
values = list
|
76
|
+
.map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
|
77
|
+
.map.with_index do |f, idx|
|
78
|
+
re = f.value
|
79
|
+
errors[idx] = f.reason if f.rejected?
|
80
|
+
re.value
|
81
|
+
end
|
82
|
+
|
83
|
+
[values, errors]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
data/lib/plumb/build.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'plumb/steppable'
|
4
|
+
|
5
|
+
module Plumb
|
6
|
+
class Build
|
7
|
+
include Steppable
|
8
|
+
|
9
|
+
attr_reader :type
|
10
|
+
|
11
|
+
def initialize(type, factory_method: :new, &block)
|
12
|
+
@type = type
|
13
|
+
@block = block || ->(value) { type.send(factory_method, value) }
|
14
|
+
end
|
15
|
+
|
16
|
+
def call(result) = result.valid(@block.call(result.value))
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'thread'
|
4
|
+
|
5
|
+
module Plumb
|
6
|
+
class Deferred
|
7
|
+
include Steppable
|
8
|
+
|
9
|
+
def initialize(definition)
|
10
|
+
@lock = Mutex.new
|
11
|
+
@definition = definition
|
12
|
+
@cached_type = nil
|
13
|
+
# freeze
|
14
|
+
end
|
15
|
+
|
16
|
+
def call(result)
|
17
|
+
cached_type.call(result)
|
18
|
+
end
|
19
|
+
|
20
|
+
private def cached_type
|
21
|
+
@lock.synchronize do
|
22
|
+
@cached_type = @definition.call
|
23
|
+
self.define_singleton_method(:cached_type) do
|
24
|
+
@cached_type
|
25
|
+
end
|
26
|
+
@cached_type
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'plumb/steppable'
|
4
|
+
require 'plumb/key'
|
5
|
+
require 'plumb/static_class'
|
6
|
+
require 'plumb/hash_map'
|
7
|
+
require 'plumb/tagged_hash'
|
8
|
+
|
9
|
+
module Plumb
|
10
|
+
class HashClass
|
11
|
+
include Steppable
|
12
|
+
|
13
|
+
attr_reader :_schema
|
14
|
+
|
15
|
+
def initialize(schema = {})
|
16
|
+
@_schema = wrap_keys_and_values(schema)
|
17
|
+
freeze
|
18
|
+
end
|
19
|
+
|
20
|
+
# A Hash type with a specific schema.
|
21
|
+
# Option 1: a Hash representing schema
|
22
|
+
#
|
23
|
+
# Types::Hash[name: Types::String.present, age?: Types::Integer]
|
24
|
+
#
|
25
|
+
# Option 2: a Map with pre-defined types for all keys and values
|
26
|
+
#
|
27
|
+
# Types::Hash[Types::String, Types::Integer]
|
28
|
+
def schema(*args)
|
29
|
+
case args
|
30
|
+
in [::Hash => hash]
|
31
|
+
self.class.new(_schema.merge(wrap_keys_and_values(hash)))
|
32
|
+
in [Steppable => key_type, Steppable => value_type]
|
33
|
+
HashMap.new(key_type, value_type)
|
34
|
+
else
|
35
|
+
raise ::ArgumentError, "unexpected value to Types::Hash#schema #{args.inspect}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
alias [] schema
|
40
|
+
|
41
|
+
# Hash#merge keeps the left-side key in the new hash
|
42
|
+
# if they match via #hash and #eql?
|
43
|
+
# we need to keep the right-side key, because even if the key name is the same,
|
44
|
+
# it's optional flag might have changed
|
45
|
+
def +(other)
|
46
|
+
raise ArgumentError, "expected a HashClass, got #{other.class}" unless other.is_a?(HashClass)
|
47
|
+
|
48
|
+
self.class.new(merge_rightmost_keys(_schema, other._schema))
|
49
|
+
end
|
50
|
+
|
51
|
+
def &(other)
|
52
|
+
raise ArgumentError, "expected a HashClass, got #{other.class}" unless other.is_a?(HashClass)
|
53
|
+
|
54
|
+
intersected_keys = other._schema.keys & _schema.keys
|
55
|
+
intersected = intersected_keys.each.with_object({}) do |k, memo|
|
56
|
+
memo[k] = other.at_key(k)
|
57
|
+
end
|
58
|
+
|
59
|
+
self.class.new(intersected)
|
60
|
+
end
|
61
|
+
|
62
|
+
def tagged_by(key, *types)
|
63
|
+
TaggedHash.new(self, key, types)
|
64
|
+
end
|
65
|
+
|
66
|
+
def at_key(a_key)
|
67
|
+
_schema[Key.wrap(a_key)]
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_h = _schema
|
71
|
+
|
72
|
+
private def _inspect
|
73
|
+
%(#{name}[#{_schema.map { |(k, v)| [k.inspect, v.inspect].join(':') }.join(' ')}])
|
74
|
+
end
|
75
|
+
|
76
|
+
def call(result)
|
77
|
+
return result.invalid(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
|
78
|
+
return result unless _schema.any?
|
79
|
+
|
80
|
+
input = result.value
|
81
|
+
errors = {}
|
82
|
+
field_result = BLANK_RESULT.dup
|
83
|
+
output = _schema.each.with_object({}) do |(key, field), ret|
|
84
|
+
key_s = key.to_sym
|
85
|
+
if input.key?(key_s)
|
86
|
+
r = field.call(field_result.reset(input[key_s]))
|
87
|
+
errors[key_s] = r.errors unless r.valid?
|
88
|
+
ret[key_s] = r.value
|
89
|
+
elsif !key.optional?
|
90
|
+
r = field.call(BLANK_RESULT)
|
91
|
+
errors[key_s] = r.errors unless r.valid?
|
92
|
+
ret[key_s] = r.value unless r.value == Undefined
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
errors.any? ? result.invalid(output, errors:) : result.valid(output)
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def wrap_keys_and_values(hash)
|
102
|
+
case hash
|
103
|
+
when ::Array
|
104
|
+
hash.map { |e| wrap_keys_and_values(e) }
|
105
|
+
when ::Hash
|
106
|
+
hash.each.with_object({}) do |(k, v), ret|
|
107
|
+
ret[Key.wrap(k)] = wrap_keys_and_values(v)
|
108
|
+
end
|
109
|
+
when Callable
|
110
|
+
hash
|
111
|
+
else # leaf values
|
112
|
+
StaticClass.new(hash)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def merge_rightmost_keys(hash1, hash2)
|
117
|
+
hash2.each.with_object(hash1.clone) do |(k, v), memo|
|
118
|
+
# assigning a key that already exist with #hash and #eql
|
119
|
+
# leaves the original key instance in place.
|
120
|
+
# but we want the hash2 key there, because its optionality could have changed.
|
121
|
+
memo.delete(k) if memo.key?(k)
|
122
|
+
memo[k] = v
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'plumb/steppable'
|
4
|
+
|
5
|
+
module Plumb
|
6
|
+
class HashMap
|
7
|
+
include Steppable
|
8
|
+
|
9
|
+
attr_reader :key_type, :value_type
|
10
|
+
|
11
|
+
def initialize(key_type, value_type)
|
12
|
+
@key_type = key_type
|
13
|
+
@value_type = value_type
|
14
|
+
freeze
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(result)
|
18
|
+
failed = result.value.lazy.filter_map do |key, value|
|
19
|
+
key_r = @key_type.resolve(key)
|
20
|
+
value_r = @value_type.resolve(value)
|
21
|
+
if !key_r.valid?
|
22
|
+
[:key, key, key_r]
|
23
|
+
elsif !value_r.valid?
|
24
|
+
[:value, value, value_r]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
if (first = failed.next)
|
28
|
+
field, val, halt = failed.first
|
29
|
+
result.invalid(errors: "#{field} #{val.inspect} #{halt.errors}")
|
30
|
+
end
|
31
|
+
rescue StopIteration
|
32
|
+
result
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'plumb/steppable'
|
4
|
+
|
5
|
+
module Plumb
|
6
|
+
class InterfaceClass
|
7
|
+
include Steppable
|
8
|
+
|
9
|
+
attr_reader :method_names
|
10
|
+
|
11
|
+
def initialize(method_names = [])
|
12
|
+
@method_names = method_names
|
13
|
+
freeze
|
14
|
+
end
|
15
|
+
|
16
|
+
def of(*args)
|
17
|
+
case args
|
18
|
+
in Array => symbols if symbols.all? { |s| s.is_a?(::Symbol) }
|
19
|
+
self.class.new(symbols)
|
20
|
+
else
|
21
|
+
raise ::ArgumentError, "unexpected value to Types::Interface#of #{args.inspect}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
alias [] of
|
26
|
+
|
27
|
+
def call(result)
|
28
|
+
obj = result.value
|
29
|
+
missing_methods = @method_names.reject { |m| obj.respond_to?(m) }
|
30
|
+
return result.invalid(errors: "missing methods: #{missing_methods.join(', ')}") if missing_methods.any?
|
31
|
+
|
32
|
+
result
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'plumb/visitor_handlers'
|
4
|
+
|
5
|
+
module Plumb
|
6
|
+
class JSONSchemaVisitor
|
7
|
+
include VisitorHandlers
|
8
|
+
|
9
|
+
TYPE = 'type'
|
10
|
+
PROPERTIES = 'properties'
|
11
|
+
REQUIRED = 'required'
|
12
|
+
DEFAULT = 'default'
|
13
|
+
ANY_OF = 'anyOf'
|
14
|
+
ALL_OF = 'allOf'
|
15
|
+
ENUM = 'enum'
|
16
|
+
CONST = 'const'
|
17
|
+
ITEMS = 'items'
|
18
|
+
PATTERN = 'pattern'
|
19
|
+
MINIMUM = 'minimum'
|
20
|
+
MAXIMUM = 'maximum'
|
21
|
+
|
22
|
+
def self.call(type)
|
23
|
+
{
|
24
|
+
'$schema' => 'https://json-schema.org/draft-08/schema#',
|
25
|
+
}.merge(new.visit(type))
|
26
|
+
end
|
27
|
+
|
28
|
+
private def stringify_keys(hash) = hash.transform_keys(&:to_s)
|
29
|
+
|
30
|
+
on(:any) do |type, props|
|
31
|
+
props
|
32
|
+
end
|
33
|
+
|
34
|
+
on(:pipeline) do |type, props|
|
35
|
+
visit(type.type, props)
|
36
|
+
end
|
37
|
+
|
38
|
+
on(:step) do |type, props|
|
39
|
+
props.merge(stringify_keys(type._metadata))
|
40
|
+
end
|
41
|
+
|
42
|
+
on(:hash) do |type, props|
|
43
|
+
props.merge(
|
44
|
+
TYPE => 'object',
|
45
|
+
PROPERTIES => type._schema.each_with_object({}) do |(key, value), hash|
|
46
|
+
hash[key.to_s] = visit(value)
|
47
|
+
end,
|
48
|
+
REQUIRED => type._schema.select { |key, value| !key.optional? }.keys.map(&:to_s)
|
49
|
+
)
|
50
|
+
end
|
51
|
+
|
52
|
+
on(:and) do |type, props|
|
53
|
+
left = visit(type.left)
|
54
|
+
right = visit(type.right)
|
55
|
+
type = right[TYPE] || left[TYPE]
|
56
|
+
props = props.merge(left).merge(right)
|
57
|
+
props = props.merge(TYPE => type) if type
|
58
|
+
props
|
59
|
+
end
|
60
|
+
|
61
|
+
# A "default" value is usually an "or" of expected_value | (undefined >> static_value)
|
62
|
+
on(:or) do |type, props|
|
63
|
+
left = visit(type.left)
|
64
|
+
right = visit(type.right)
|
65
|
+
any_of = [left, right].uniq
|
66
|
+
if any_of.size == 1
|
67
|
+
props.merge(left)
|
68
|
+
elsif any_of.size == 2 && (defidx = any_of.index { |p| p.key?(DEFAULT) })
|
69
|
+
val = any_of[defidx == 0 ? 1 : 0]
|
70
|
+
props.merge(val).merge(DEFAULT => any_of[defidx][DEFAULT])
|
71
|
+
else
|
72
|
+
props.merge(ANY_OF => any_of)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
on(:value) do |type, props|
|
77
|
+
props = case type.value
|
78
|
+
when ::String, ::Symbol, ::Numeric
|
79
|
+
props.merge(CONST => type.value)
|
80
|
+
else
|
81
|
+
props
|
82
|
+
end
|
83
|
+
|
84
|
+
visit(type.value, props)
|
85
|
+
end
|
86
|
+
|
87
|
+
on(:transform) do |type, props|
|
88
|
+
visit(type.target_type, props)
|
89
|
+
end
|
90
|
+
|
91
|
+
on(:undefined) do |type, props|
|
92
|
+
props
|
93
|
+
end
|
94
|
+
|
95
|
+
on(:static) do |type, props|
|
96
|
+
props = case type.value
|
97
|
+
when ::String, ::Symbol, ::Numeric
|
98
|
+
props.merge(CONST => type.value, DEFAULT => type.value)
|
99
|
+
else
|
100
|
+
props
|
101
|
+
end
|
102
|
+
|
103
|
+
visit(type.value, props)
|
104
|
+
end
|
105
|
+
|
106
|
+
on(:rules) do |type, props|
|
107
|
+
type.rules.reduce(props) do |acc, rule|
|
108
|
+
acc.merge(visit(rule))
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
on(:rule_included_in) do |type, props|
|
113
|
+
props.merge(ENUM => type.arg_value)
|
114
|
+
end
|
115
|
+
|
116
|
+
on(:match) do |type, props|
|
117
|
+
visit(type.matcher, props)
|
118
|
+
end
|
119
|
+
|
120
|
+
on(:boolean) do |type, props|
|
121
|
+
props.merge(TYPE => 'boolean')
|
122
|
+
end
|
123
|
+
|
124
|
+
on(::String) do |type, props|
|
125
|
+
props.merge(TYPE => 'string')
|
126
|
+
end
|
127
|
+
|
128
|
+
on(::Integer) do |type, props|
|
129
|
+
props.merge(TYPE => 'integer')
|
130
|
+
end
|
131
|
+
|
132
|
+
on(::Numeric) do |type, props|
|
133
|
+
props.merge(TYPE => 'number')
|
134
|
+
end
|
135
|
+
|
136
|
+
on(::BigDecimal) do |type, props|
|
137
|
+
props.merge(TYPE => 'number')
|
138
|
+
end
|
139
|
+
|
140
|
+
on(::Float) do |type, props|
|
141
|
+
props.merge(TYPE => 'number')
|
142
|
+
end
|
143
|
+
|
144
|
+
on(::TrueClass) do |type, props|
|
145
|
+
props.merge(TYPE => 'boolean')
|
146
|
+
end
|
147
|
+
|
148
|
+
on(::NilClass) do |type, props|
|
149
|
+
props.merge(TYPE => 'null')
|
150
|
+
end
|
151
|
+
|
152
|
+
on(::FalseClass) do |type, props|
|
153
|
+
props.merge(TYPE => 'boolean')
|
154
|
+
end
|
155
|
+
|
156
|
+
on(::Regexp) do |type, props|
|
157
|
+
props.merge(PATTERN => type.source)
|
158
|
+
end
|
159
|
+
|
160
|
+
on(::Range) do |type, props|
|
161
|
+
opts = {}
|
162
|
+
opts[MINIMUM] = type.min if type.begin
|
163
|
+
opts[MAXIMUM] = type.max if type.end
|
164
|
+
props.merge(opts)
|
165
|
+
end
|
166
|
+
|
167
|
+
on(:metadata) do |type, props|
|
168
|
+
# TODO: here we should filter out the metadata that is not relevant for JSON Schema
|
169
|
+
props.merge(stringify_keys(type.metadata))
|
170
|
+
end
|
171
|
+
|
172
|
+
on(:hash_map) do |type, props|
|
173
|
+
{
|
174
|
+
TYPE => 'object',
|
175
|
+
'patternProperties' => {
|
176
|
+
'.*' => visit(type.value_type)
|
177
|
+
}
|
178
|
+
}
|
179
|
+
end
|
180
|
+
|
181
|
+
on(:build) do |type, props|
|
182
|
+
visit(type.type, props)
|
183
|
+
end
|
184
|
+
|
185
|
+
on(:array) do |type, props|
|
186
|
+
items = visit(type.element_type)
|
187
|
+
{ TYPE => 'array', ITEMS => items }
|
188
|
+
end
|
189
|
+
|
190
|
+
on(:tuple) do |type, props|
|
191
|
+
items = type.types.map { |t| visit(t) }
|
192
|
+
{ TYPE => 'array', 'prefixItems' => items }
|
193
|
+
end
|
194
|
+
|
195
|
+
on(:tagged_hash) do |type, props|
|
196
|
+
required = Set.new
|
197
|
+
result = {
|
198
|
+
TYPE => 'object',
|
199
|
+
PROPERTIES => {}
|
200
|
+
}
|
201
|
+
|
202
|
+
key = type.key.to_s
|
203
|
+
children = type.types.map { |c| visit(c) }
|
204
|
+
key_enum = children.map { |c| c[PROPERTIES][key][CONST] }
|
205
|
+
key_type = children.map { |c| c[PROPERTIES][key][TYPE] }
|
206
|
+
required << key
|
207
|
+
result[PROPERTIES][key] = { TYPE => key_type.first, ENUM => key_enum }
|
208
|
+
result[ALL_OF] = children.map do |child|
|
209
|
+
child_prop = child[PROPERTIES][key]
|
210
|
+
|
211
|
+
{
|
212
|
+
'if' => {
|
213
|
+
PROPERTIES => { key => child_prop.slice(CONST, TYPE) }
|
214
|
+
},
|
215
|
+
'then' => child.except(TYPE)
|
216
|
+
}
|
217
|
+
end
|
218
|
+
|
219
|
+
result.merge(REQUIRED => required.to_a)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
data/lib/plumb/key.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Plumb
|
4
|
+
class Key
|
5
|
+
OPTIONAL_EXP = /(\w+)(\?)?$/
|
6
|
+
|
7
|
+
def self.wrap(key)
|
8
|
+
key.is_a?(Key) ? key : new(key)
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :to_sym, :node_name
|
12
|
+
|
13
|
+
def initialize(key, optional: false)
|
14
|
+
key_s = key.to_s
|
15
|
+
match = OPTIONAL_EXP.match(key_s)
|
16
|
+
@node_name = :key
|
17
|
+
@key = match[1]
|
18
|
+
@to_sym = @key.to_sym
|
19
|
+
@optional = !match[2].nil? ? true : optional
|
20
|
+
freeze
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s = @key
|
24
|
+
|
25
|
+
def hash
|
26
|
+
@key.hash
|
27
|
+
end
|
28
|
+
|
29
|
+
def eql?(other)
|
30
|
+
other.hash == hash
|
31
|
+
end
|
32
|
+
|
33
|
+
def optional?
|
34
|
+
@optional
|
35
|
+
end
|
36
|
+
|
37
|
+
def inspect
|
38
|
+
"#{@key}#{'?' if @optional}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'plumb/steppable'
|
4
|
+
|
5
|
+
module Plumb
|
6
|
+
class MatchClass
|
7
|
+
include Steppable
|
8
|
+
|
9
|
+
attr_reader :matcher
|
10
|
+
|
11
|
+
def initialize(matcher = Undefined, error: nil)
|
12
|
+
raise TypeError 'matcher must respond to #===' unless matcher.respond_to?(:===)
|
13
|
+
|
14
|
+
@matcher = matcher
|
15
|
+
@error = error.nil? ? build_error(matcher) : (error % matcher)
|
16
|
+
end
|
17
|
+
|
18
|
+
def inspect
|
19
|
+
%(#{name}[#{@matcher.inspect}])
|
20
|
+
end
|
21
|
+
|
22
|
+
def call(result)
|
23
|
+
@matcher === result.value ? result : result.invalid(errors: @error)
|
24
|
+
end
|
25
|
+
|
26
|
+
private def build_error(matcher)
|
27
|
+
case matcher
|
28
|
+
when Class # A class primitive, ex. String, Integer, etc.
|
29
|
+
"Must be a #{matcher}"
|
30
|
+
when ::String, ::Symbol, ::Numeric, ::TrueClass, ::FalseClass, ::NilClass, ::Array, ::Hash
|
31
|
+
"Must be equal to #{matcher}"
|
32
|
+
when ::Range
|
33
|
+
"Must be within #{matcher}"
|
34
|
+
else
|
35
|
+
"Must match #{matcher.inspect}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|