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
@@ -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
|