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