rschema 2.4.0 → 3.0.1.pre1
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 +4 -4
- data/README.md +408 -197
- data/lib/rschema.rb +26 -367
- data/lib/rschema/dsl.rb +103 -0
- data/lib/rschema/error.rb +46 -0
- data/lib/rschema/http_coercer.rb +177 -0
- data/lib/rschema/options.rb +15 -0
- data/lib/rschema/result.rb +39 -0
- data/lib/rschema/schemas/anything.rb +17 -0
- data/lib/rschema/schemas/boolean.rb +27 -0
- data/lib/rschema/schemas/enum.rb +31 -0
- data/lib/rschema/schemas/fixed_hash.rb +118 -0
- data/lib/rschema/schemas/fixed_length_array.rb +60 -0
- data/lib/rschema/schemas/maybe.rb +23 -0
- data/lib/rschema/schemas/pipeline.rb +27 -0
- data/lib/rschema/schemas/predicate.rb +27 -0
- data/lib/rschema/schemas/set.rb +56 -0
- data/lib/rschema/schemas/sum.rb +36 -0
- data/lib/rschema/schemas/type.rb +27 -0
- data/lib/rschema/schemas/variable_hash.rb +67 -0
- data/lib/rschema/schemas/variable_length_array.rb +49 -0
- data/lib/rschema/version.rb +1 -1
- metadata +27 -10
- data/lib/rschema/rails_interop.rb +0 -19
@@ -0,0 +1,46 @@
|
|
1
|
+
module RSchema
|
2
|
+
class Error
|
3
|
+
attr_reader :schema, :value, :symbolic_name, :vars
|
4
|
+
|
5
|
+
def initialize(schema:, value:, symbolic_name:, vars: nil)
|
6
|
+
@schema = schema
|
7
|
+
@value = value
|
8
|
+
@symbolic_name = symbolic_name
|
9
|
+
@vars = vars
|
10
|
+
freeze
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_s(detailed=false)
|
14
|
+
if detailed
|
15
|
+
<<~EOS
|
16
|
+
Error: #{symbolic_name}
|
17
|
+
Schema: #{schema.class.name}
|
18
|
+
Value: #{value.inspect}
|
19
|
+
Vars: #{vars.inspect}
|
20
|
+
EOS
|
21
|
+
else
|
22
|
+
"Error #{schema.class}/#{symbolic_name} for value: #{value.inspect}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_json
|
27
|
+
{
|
28
|
+
schema: schema.class.name,
|
29
|
+
error: symbolic_name.to_s,
|
30
|
+
value: jsonify(value),
|
31
|
+
vars: jsonify(vars),
|
32
|
+
}
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def jsonify(value)
|
38
|
+
case value
|
39
|
+
when String, Symbol, Numeric, TrueClass, FalseClass, NilClass then value
|
40
|
+
when Array then value.map{ |element| jsonify(element) }
|
41
|
+
when Hash then value.map{ |k, v| [jsonify(k), jsonify(v)] }.to_h
|
42
|
+
else String(value)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
module RSchema
|
2
|
+
module HTTPCoercer
|
3
|
+
class CanNotBeWrappedError < StandardError; end
|
4
|
+
|
5
|
+
def self.wrap(schema)
|
6
|
+
coercer_klass = begin
|
7
|
+
case schema
|
8
|
+
when Schemas::Type then TYPE_COERCERS[schema.type]
|
9
|
+
when Schemas::Boolean then BoolCoercer
|
10
|
+
when Schemas::FixedHash then FixedHashCoercer
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
wrapped_schema = schema.with_wrapped_subschemas(self)
|
15
|
+
coercer_klass ? coercer_klass.new(wrapped_schema) : wrapped_schema
|
16
|
+
end
|
17
|
+
|
18
|
+
class Coercer
|
19
|
+
attr_reader :subschema
|
20
|
+
|
21
|
+
def initialize(subschema)
|
22
|
+
@subschema = subschema
|
23
|
+
end
|
24
|
+
|
25
|
+
def call(value, options=RSchema::Options.default)
|
26
|
+
@subschema.call(coerce(value), options)
|
27
|
+
rescue CoercionFailedError
|
28
|
+
return Result.failure(Error.new(
|
29
|
+
schema: self,
|
30
|
+
value: value,
|
31
|
+
symbolic_name: :coercion_failure,
|
32
|
+
))
|
33
|
+
end
|
34
|
+
|
35
|
+
def with_wrapped_subschemas(wrapper)
|
36
|
+
raise CanNotBeWrappedError, <<~EOS
|
37
|
+
This schema has already been wrapped by RSchema::HTTPCoercer.
|
38
|
+
Wrapping the schema again will most likely result in a schema that
|
39
|
+
crashes when it is called.
|
40
|
+
EOS
|
41
|
+
end
|
42
|
+
|
43
|
+
def invalid!
|
44
|
+
raise CoercionFailedError
|
45
|
+
end
|
46
|
+
|
47
|
+
class CoercionFailedError < StandardError; end
|
48
|
+
end
|
49
|
+
|
50
|
+
class TimeCoercer < Coercer
|
51
|
+
def coerce(value)
|
52
|
+
case value
|
53
|
+
when Time then value
|
54
|
+
when String then Time.iso8601(value) rescue invalid!
|
55
|
+
else invalid!
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
class DateCoercer < Coercer
|
61
|
+
def coerce(value)
|
62
|
+
case value
|
63
|
+
when Date then value
|
64
|
+
when String then Date.iso8601(value) rescue invalid!
|
65
|
+
else invalid!
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
class SymbolCoercer < Coercer
|
71
|
+
def coerce(value)
|
72
|
+
case value
|
73
|
+
when Symbol then value
|
74
|
+
when String then value.to_sym
|
75
|
+
else invalid!
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
class IntegerCoercer < Coercer
|
81
|
+
def coerce(value)
|
82
|
+
Integer(value)
|
83
|
+
rescue ArgumentError
|
84
|
+
invalid!
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
class FloatCoercer < Coercer
|
89
|
+
def coerce(value)
|
90
|
+
Float(value)
|
91
|
+
rescue ArgumentError
|
92
|
+
invalid!
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
class BoolCoercer < Coercer
|
97
|
+
TRUTHY_STRINGS = ['on', '1', 'true']
|
98
|
+
FALSEY_STRINGS = ['off', '0', 'false']
|
99
|
+
|
100
|
+
def coerce(value)
|
101
|
+
case value
|
102
|
+
when true, false then value
|
103
|
+
when nil then false
|
104
|
+
when String
|
105
|
+
case
|
106
|
+
when TRUTHY_STRINGS.include?(value.downcase) then true
|
107
|
+
when FALSEY_STRINGS.include?(value.downcase) then false
|
108
|
+
else invalid!
|
109
|
+
end
|
110
|
+
else invalid!
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
class FixedHashCoercer < Coercer
|
116
|
+
def coerce(value)
|
117
|
+
default_bools_to_false(symbolize_keys(value))
|
118
|
+
end
|
119
|
+
|
120
|
+
def symbolize_keys(hash)
|
121
|
+
keys = keys_to_symbolize(hash)
|
122
|
+
if keys.any?
|
123
|
+
hash.dup.tap do |new_hash|
|
124
|
+
keys.each { |k| new_hash[k.to_sym] = new_hash.delete(k) }
|
125
|
+
end
|
126
|
+
else
|
127
|
+
hash
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def keys_to_symbolize(hash)
|
132
|
+
# these could be cached if we know for sure that the subschema is immutable
|
133
|
+
symbol_keys = subschema.attributes
|
134
|
+
.map(&:key)
|
135
|
+
.select{ |k| k.is_a?(Symbol) }
|
136
|
+
.map(&:to_s)
|
137
|
+
|
138
|
+
string_keys = subschema.attributes
|
139
|
+
.map(&:key)
|
140
|
+
.select{ |k| k.is_a?(String) }
|
141
|
+
|
142
|
+
hash.keys.select do |k|
|
143
|
+
k.is_a?(String) && symbol_keys.include?(k) && !string_keys.include?(k)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def default_bools_to_false(hash)
|
148
|
+
# The HTTP standard says that when a form is submitted, all unchecked
|
149
|
+
# check boxes will _not_ be sent to the server. That is, they will not
|
150
|
+
# be present at all in the params hash.
|
151
|
+
#
|
152
|
+
# This method coerces these missing values into `false`.
|
153
|
+
|
154
|
+
# some of this could be cached if we know for sure that the subschema is immutable
|
155
|
+
keys_to_default = subschema.attributes
|
156
|
+
.select { |attr| attr.value_schema.is_a?(BoolCoercer) }
|
157
|
+
.map(&:key)
|
158
|
+
.reject { |key| hash.has_key?(key) }
|
159
|
+
|
160
|
+
if keys_to_default.any?
|
161
|
+
defaults = keys_to_default.map{ |k| [k, false] }.to_h
|
162
|
+
hash.merge(defaults)
|
163
|
+
else
|
164
|
+
hash # no coercion necessary
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
TYPE_COERCERS = {
|
170
|
+
Symbol => SymbolCoercer,
|
171
|
+
Integer => IntegerCoercer,
|
172
|
+
Float => FloatCoercer,
|
173
|
+
Time => TimeCoercer,
|
174
|
+
Date => DateCoercer,
|
175
|
+
}
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module RSchema
|
2
|
+
class Result
|
3
|
+
def self.success(value)
|
4
|
+
new(true, value, nil)
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.failure(error)
|
8
|
+
new(false, nil, error)
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(valid, value, error)
|
12
|
+
@valid = valid
|
13
|
+
@value = value
|
14
|
+
@error = error
|
15
|
+
end
|
16
|
+
|
17
|
+
def valid?
|
18
|
+
@valid
|
19
|
+
end
|
20
|
+
|
21
|
+
def invalid?
|
22
|
+
not valid?
|
23
|
+
end
|
24
|
+
|
25
|
+
def value
|
26
|
+
if valid?
|
27
|
+
@value
|
28
|
+
else
|
29
|
+
raise InvalidError
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def error
|
34
|
+
@error
|
35
|
+
end
|
36
|
+
|
37
|
+
class InvalidError < StandardError; end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module RSchema
|
2
|
+
module Schemas
|
3
|
+
class Anything
|
4
|
+
def self.instance
|
5
|
+
@instance ||= new
|
6
|
+
end
|
7
|
+
|
8
|
+
def call(value, options=Options.default)
|
9
|
+
Result.success(value)
|
10
|
+
end
|
11
|
+
|
12
|
+
def with_wrapped_subschemas(wrapper)
|
13
|
+
self
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module RSchema
|
2
|
+
module Schemas
|
3
|
+
|
4
|
+
class Boolean
|
5
|
+
def self.instance
|
6
|
+
@instance ||= new
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(value, options=Options.default)
|
10
|
+
if value.equal?(true) || value.equal?(false)
|
11
|
+
Result.success(value)
|
12
|
+
else
|
13
|
+
Result.failure(Error.new(
|
14
|
+
schema: self,
|
15
|
+
value: value,
|
16
|
+
symbolic_name: :not_a_boolean,
|
17
|
+
))
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def with_wrapped_subschemas(wrapper)
|
22
|
+
self
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module RSchema
|
2
|
+
module Schemas
|
3
|
+
class Enum
|
4
|
+
attr_reader :members, :subschema
|
5
|
+
|
6
|
+
def initialize(members, subschema)
|
7
|
+
@members = members
|
8
|
+
@subschema = subschema
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(value, options=Options.default)
|
12
|
+
subresult = subschema.call(value, options)
|
13
|
+
if subresult.invalid?
|
14
|
+
subresult
|
15
|
+
elsif members.include?(subresult.value)
|
16
|
+
subresult
|
17
|
+
else
|
18
|
+
Result.failure(Error.new(
|
19
|
+
schema: self,
|
20
|
+
value: subresult.value,
|
21
|
+
symbolic_name: :not_a_member,
|
22
|
+
))
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def with_wrapped_subschemas(wrapper)
|
27
|
+
self.class.new(members, wrapper.wrap(subschema))
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
module RSchema
|
2
|
+
module Schemas
|
3
|
+
|
4
|
+
class FixedHash
|
5
|
+
attr_reader :attributes
|
6
|
+
|
7
|
+
def initialize(attributes)
|
8
|
+
@attributes = attributes
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(value, options=Options.default)
|
12
|
+
return not_a_hash_result(value) unless value.is_a?(Hash)
|
13
|
+
return missing_attrs_result(value) if missing_keys(value).any?
|
14
|
+
return extraneous_attrs_result(value) if extraneous_keys(value).any?
|
15
|
+
|
16
|
+
subresults = attr_subresults(value, options)
|
17
|
+
if subresults.values.any?(&:invalid?)
|
18
|
+
Result.failure(failure_error(subresults))
|
19
|
+
else
|
20
|
+
Result.success(success_value(subresults))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def with_wrapped_subschemas(wrapper)
|
25
|
+
wrapped_attributes = attributes.map do |attr|
|
26
|
+
attr.with_wrapped_value_schema(wrapper)
|
27
|
+
end
|
28
|
+
|
29
|
+
self.class.new(wrapped_attributes)
|
30
|
+
end
|
31
|
+
|
32
|
+
def [](attr_key)
|
33
|
+
attributes.find{ |attr| attr.key == attr_key }
|
34
|
+
end
|
35
|
+
|
36
|
+
Attribute = Struct.new(:key, :value_schema, :optional) do
|
37
|
+
def with_wrapped_value_schema(wrapper)
|
38
|
+
self.class.new(key, wrapper.wrap(value_schema), optional)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def missing_keys(value)
|
45
|
+
attributes
|
46
|
+
.reject(&:optional)
|
47
|
+
.map(&:key)
|
48
|
+
.reject{ |k| value.has_key?(k) }
|
49
|
+
end
|
50
|
+
|
51
|
+
def missing_attrs_result(value)
|
52
|
+
Result.failure(Error.new(
|
53
|
+
schema: self,
|
54
|
+
value: value,
|
55
|
+
symbolic_name: :missing_attributes,
|
56
|
+
vars: missing_keys(value),
|
57
|
+
))
|
58
|
+
end
|
59
|
+
|
60
|
+
def extraneous_keys(value)
|
61
|
+
allowed_keys = attributes.map(&:key)
|
62
|
+
value.keys.reject{ |k| allowed_keys.include?(k) }
|
63
|
+
end
|
64
|
+
|
65
|
+
def extraneous_attrs_result(value)
|
66
|
+
Result.failure(Error.new(
|
67
|
+
schema: self,
|
68
|
+
value: value,
|
69
|
+
symbolic_name: :extraneous_attributes,
|
70
|
+
vars: extraneous_keys(value),
|
71
|
+
))
|
72
|
+
end
|
73
|
+
|
74
|
+
def attr_subresults(value, options)
|
75
|
+
subresults_by_key = {}
|
76
|
+
|
77
|
+
@attributes.map do |attr|
|
78
|
+
if value.has_key?(attr.key)
|
79
|
+
subresult = attr.value_schema.call(value[attr.key], options)
|
80
|
+
subresults_by_key[attr.key] = subresult
|
81
|
+
break if subresult.invalid? && options.fail_fast?
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
subresults_by_key
|
86
|
+
end
|
87
|
+
|
88
|
+
def failure_error(results)
|
89
|
+
error = {}
|
90
|
+
|
91
|
+
results.each do |key, attr_result|
|
92
|
+
if attr_result.invalid?
|
93
|
+
error[key] = attr_result.error
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
error
|
98
|
+
end
|
99
|
+
|
100
|
+
def success_value(subresults)
|
101
|
+
subresults
|
102
|
+
.map{ |key, attr_result| [key, attr_result.value] }
|
103
|
+
.to_h
|
104
|
+
end
|
105
|
+
|
106
|
+
def not_a_hash_result(value)
|
107
|
+
Result.failure(
|
108
|
+
Error.new(
|
109
|
+
schema: self,
|
110
|
+
value: value,
|
111
|
+
symbolic_name: :not_a_hash,
|
112
|
+
)
|
113
|
+
)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
end
|
118
|
+
end
|