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