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.
@@ -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,15 @@
1
+ module RSchema
2
+ class Options
3
+ def self.default
4
+ @default ||= new
5
+ end
6
+
7
+ def initialize(vars={})
8
+ @fail_fast = vars.fetch(:fail_fast, false)
9
+ end
10
+
11
+ def fail_fast?
12
+ @fail_fast
13
+ end
14
+ end
15
+ 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