anyvali 0.0.1
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 +7 -0
- data/README.md +339 -0
- data/lib/anyvali/anyvali_document.rb +43 -0
- data/lib/anyvali/format/validators.rb +89 -0
- data/lib/anyvali/interchange/exporter.rb +40 -0
- data/lib/anyvali/interchange/importer.rb +201 -0
- data/lib/anyvali/issue_codes.rb +20 -0
- data/lib/anyvali/parse/coercion.rb +93 -0
- data/lib/anyvali/parse/coercion_config.rb +19 -0
- data/lib/anyvali/parse/defaults.rb +21 -0
- data/lib/anyvali/parse_result.rb +21 -0
- data/lib/anyvali/schema.rb +132 -0
- data/lib/anyvali/schemas/any_schema.rb +15 -0
- data/lib/anyvali/schemas/array_schema.rb +77 -0
- data/lib/anyvali/schemas/bool_schema.rb +22 -0
- data/lib/anyvali/schemas/enum_schema.rb +50 -0
- data/lib/anyvali/schemas/int_schema.rb +9 -0
- data/lib/anyvali/schemas/intersection_schema.rb +65 -0
- data/lib/anyvali/schemas/literal_schema.rb +52 -0
- data/lib/anyvali/schemas/never_schema.rb +20 -0
- data/lib/anyvali/schemas/null_schema.rb +22 -0
- data/lib/anyvali/schemas/nullable_schema.rb +40 -0
- data/lib/anyvali/schemas/number_schema.rb +214 -0
- data/lib/anyvali/schemas/object_schema.rb +150 -0
- data/lib/anyvali/schemas/optional_schema.rb +47 -0
- data/lib/anyvali/schemas/record_schema.rb +51 -0
- data/lib/anyvali/schemas/ref_schema.rb +58 -0
- data/lib/anyvali/schemas/string_schema.rb +113 -0
- data/lib/anyvali/schemas/tuple_schema.rb +72 -0
- data/lib/anyvali/schemas/union_schema.rb +50 -0
- data/lib/anyvali/schemas/unknown_schema.rb +15 -0
- data/lib/anyvali/validation_context.rb +11 -0
- data/lib/anyvali/validation_error.rb +13 -0
- data/lib/anyvali/validation_issue.rb +45 -0
- data/lib/anyvali.rb +176 -0
- metadata +107 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class IntersectionSchema < Schema
|
|
5
|
+
attr_reader :all_of_schemas
|
|
6
|
+
|
|
7
|
+
def initialize(all_of:, **kwargs)
|
|
8
|
+
@all_of_schemas = all_of.freeze
|
|
9
|
+
super(kind: "intersection", **kwargs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_node
|
|
13
|
+
node = super
|
|
14
|
+
node["allOf"] = @all_of_schemas.map(&:to_node)
|
|
15
|
+
node
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def safe_parse(input, path: [], context: nil)
|
|
19
|
+
context ||= ValidationContext.new
|
|
20
|
+
all_issues = []
|
|
21
|
+
merged_output = nil
|
|
22
|
+
|
|
23
|
+
@all_of_schemas.each do |schema|
|
|
24
|
+
result = schema.safe_parse(input, path: path, context: context)
|
|
25
|
+
if result.failure?
|
|
26
|
+
all_issues.concat(result.issues)
|
|
27
|
+
else
|
|
28
|
+
if merged_output.nil?
|
|
29
|
+
merged_output = result.value
|
|
30
|
+
elsif merged_output.is_a?(Hash) && result.value.is_a?(Hash)
|
|
31
|
+
merged_output = merged_output.merge(result.value)
|
|
32
|
+
else
|
|
33
|
+
merged_output = result.value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if all_issues.empty?
|
|
39
|
+
ParseResult.new(value: merged_output, issues: [])
|
|
40
|
+
else
|
|
41
|
+
ParseResult.new(value: nil, issues: all_issues)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
protected
|
|
46
|
+
|
|
47
|
+
def validate(value, path, issues, context)
|
|
48
|
+
# Handled in safe_parse override
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def dup_with(**overrides)
|
|
52
|
+
all_of = overrides.delete(:all_of) || @all_of_schemas
|
|
53
|
+
attrs = {
|
|
54
|
+
all_of: all_of,
|
|
55
|
+
kind: @kind,
|
|
56
|
+
constraints: @constraints,
|
|
57
|
+
coerce_config: @coerce_config,
|
|
58
|
+
default_value: @default_value,
|
|
59
|
+
has_default: @has_default,
|
|
60
|
+
custom_validators: @custom_validators
|
|
61
|
+
}.merge(overrides)
|
|
62
|
+
self.class.new(**attrs)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class LiteralSchema < Schema
|
|
5
|
+
attr_reader :literal_value
|
|
6
|
+
|
|
7
|
+
def initialize(value:, **kwargs)
|
|
8
|
+
@literal_value = value
|
|
9
|
+
super(kind: "literal", **kwargs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_node
|
|
13
|
+
node = super
|
|
14
|
+
node["value"] = @literal_value
|
|
15
|
+
node
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
protected
|
|
19
|
+
|
|
20
|
+
def validate(value, path, issues, context)
|
|
21
|
+
# Strict equality: must be same type and value
|
|
22
|
+
unless value.class == @literal_value.class && value == @literal_value
|
|
23
|
+
# Special case: nil comparisons
|
|
24
|
+
if @literal_value.nil? && value.nil?
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
# Handle Integer/Float comparisons: both are "number" but Integer 42 != Float 42.0 for literal
|
|
28
|
+
expected_str = @literal_value.nil? ? "null" : @literal_value.to_s
|
|
29
|
+
received_str = value.nil? ? "null" : value.to_s
|
|
30
|
+
issues << ValidationIssue.new(
|
|
31
|
+
code: IssueCodes::INVALID_LITERAL,
|
|
32
|
+
path: path,
|
|
33
|
+
expected: expected_str,
|
|
34
|
+
received: received_str
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def dup_with(**overrides)
|
|
40
|
+
attrs = {
|
|
41
|
+
value: @literal_value,
|
|
42
|
+
kind: @kind,
|
|
43
|
+
constraints: @constraints,
|
|
44
|
+
coerce_config: @coerce_config,
|
|
45
|
+
default_value: @default_value,
|
|
46
|
+
has_default: @has_default,
|
|
47
|
+
custom_validators: @custom_validators
|
|
48
|
+
}.merge(overrides)
|
|
49
|
+
self.class.new(**attrs)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class NeverSchema < Schema
|
|
5
|
+
def initialize(**kwargs)
|
|
6
|
+
super(kind: "never", **kwargs)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def validate(value, path, issues, context)
|
|
12
|
+
issues << ValidationIssue.new(
|
|
13
|
+
code: IssueCodes::INVALID_TYPE,
|
|
14
|
+
path: path,
|
|
15
|
+
expected: "never",
|
|
16
|
+
received: Schema.type_name(value)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class NullSchema < Schema
|
|
5
|
+
def initialize(**kwargs)
|
|
6
|
+
super(kind: "null", **kwargs)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def validate(value, path, issues, context)
|
|
12
|
+
unless value.nil?
|
|
13
|
+
issues << ValidationIssue.new(
|
|
14
|
+
code: IssueCodes::INVALID_TYPE,
|
|
15
|
+
path: path,
|
|
16
|
+
expected: "null",
|
|
17
|
+
received: Schema.type_name(value)
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class NullableSchema < Schema
|
|
5
|
+
attr_reader :inner_schema
|
|
6
|
+
|
|
7
|
+
def initialize(schema:, **kwargs)
|
|
8
|
+
@inner_schema = schema
|
|
9
|
+
super(kind: "nullable", **kwargs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_node
|
|
13
|
+
node = super
|
|
14
|
+
node["schema"] = @inner_schema.to_node
|
|
15
|
+
node
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
protected
|
|
19
|
+
|
|
20
|
+
def validate(value, path, issues, context)
|
|
21
|
+
return if value.nil?
|
|
22
|
+
result = @inner_schema.safe_parse(value, path: path, context: context)
|
|
23
|
+
issues.concat(result.issues) if result.failure?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def dup_with(**overrides)
|
|
27
|
+
schema = overrides.delete(:schema) || @inner_schema
|
|
28
|
+
attrs = {
|
|
29
|
+
schema: schema,
|
|
30
|
+
kind: @kind,
|
|
31
|
+
constraints: @constraints,
|
|
32
|
+
coerce_config: @coerce_config,
|
|
33
|
+
default_value: @default_value,
|
|
34
|
+
has_default: @has_default,
|
|
35
|
+
custom_validators: @custom_validators
|
|
36
|
+
}.merge(overrides)
|
|
37
|
+
self.class.new(**attrs)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class NumberSchema < Schema
|
|
5
|
+
FLOAT_KINDS = %w[number float32 float64].freeze
|
|
6
|
+
INT_KINDS = %w[int int8 int16 int32 int64 uint8 uint16 uint32 uint64].freeze
|
|
7
|
+
|
|
8
|
+
INT_RANGES = {
|
|
9
|
+
"int8" => [-128, 127],
|
|
10
|
+
"int16" => [-32_768, 32_767],
|
|
11
|
+
"int32" => [-2_147_483_648, 2_147_483_647],
|
|
12
|
+
"int64" => [-9_223_372_036_854_775_808, 9_223_372_036_854_775_807],
|
|
13
|
+
"uint8" => [0, 255],
|
|
14
|
+
"uint16" => [0, 65_535],
|
|
15
|
+
"uint32" => [0, 4_294_967_295],
|
|
16
|
+
"uint64" => [0, 18_446_744_073_709_551_615],
|
|
17
|
+
"int" => [-9_223_372_036_854_775_808, 9_223_372_036_854_775_807]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
FLOAT32_MAX = 3.4028235e+38
|
|
21
|
+
|
|
22
|
+
def initialize(kind: "number", constraints: {}, **kwargs)
|
|
23
|
+
super(kind: kind, constraints: constraints, **kwargs)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def min(n)
|
|
27
|
+
dup_with(constraints: @constraints.merge("min" => n))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def max(n)
|
|
31
|
+
dup_with(constraints: @constraints.merge("max" => n))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def exclusive_min(n)
|
|
35
|
+
dup_with(constraints: @constraints.merge("exclusiveMin" => n))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def exclusive_max(n)
|
|
39
|
+
dup_with(constraints: @constraints.merge("exclusiveMax" => n))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def multiple_of(n)
|
|
43
|
+
dup_with(constraints: @constraints.merge("multipleOf" => n))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
protected
|
|
47
|
+
|
|
48
|
+
def validate(value, path, issues, context)
|
|
49
|
+
int_kind = INT_KINDS.include?(@kind)
|
|
50
|
+
float_kind = FLOAT_KINDS.include?(@kind)
|
|
51
|
+
|
|
52
|
+
if int_kind
|
|
53
|
+
validate_int(value, path, issues)
|
|
54
|
+
elsif float_kind
|
|
55
|
+
validate_float(value, path, issues)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate_int(value, path, issues)
|
|
62
|
+
# Must be numeric
|
|
63
|
+
unless value.is_a?(Integer) || value.is_a?(Float)
|
|
64
|
+
issues << ValidationIssue.new(
|
|
65
|
+
code: IssueCodes::INVALID_TYPE,
|
|
66
|
+
path: path,
|
|
67
|
+
expected: @kind,
|
|
68
|
+
received: Schema.type_name(value)
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Must be a whole number
|
|
74
|
+
if value.is_a?(Float) && value != value.floor
|
|
75
|
+
issues << ValidationIssue.new(
|
|
76
|
+
code: IssueCodes::INVALID_TYPE,
|
|
77
|
+
path: path,
|
|
78
|
+
expected: @kind,
|
|
79
|
+
received: "number"
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
int_val = value.is_a?(Float) ? value.to_i : value
|
|
85
|
+
|
|
86
|
+
# Width range check
|
|
87
|
+
if INT_RANGES.key?(@kind)
|
|
88
|
+
min_val, max_val = INT_RANGES[@kind]
|
|
89
|
+
if int_val < min_val
|
|
90
|
+
issues << ValidationIssue.new(
|
|
91
|
+
code: IssueCodes::TOO_SMALL,
|
|
92
|
+
path: path,
|
|
93
|
+
expected: @kind,
|
|
94
|
+
received: int_val.to_s
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
if int_val > max_val
|
|
99
|
+
issues << ValidationIssue.new(
|
|
100
|
+
code: IssueCodes::TOO_LARGE,
|
|
101
|
+
path: path,
|
|
102
|
+
expected: @kind,
|
|
103
|
+
received: int_val.to_s
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
validate_numeric_constraints(int_val, path, issues)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validate_float(value, path, issues)
|
|
113
|
+
unless value.is_a?(Numeric) && !value.is_a?(Complex)
|
|
114
|
+
issues << ValidationIssue.new(
|
|
115
|
+
code: IssueCodes::INVALID_TYPE,
|
|
116
|
+
path: path,
|
|
117
|
+
expected: @kind,
|
|
118
|
+
received: Schema.type_name(value)
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# For booleans (true/false are not Numeric in Ruby, so this is fine)
|
|
124
|
+
if value == true || value == false
|
|
125
|
+
issues << ValidationIssue.new(
|
|
126
|
+
code: IssueCodes::INVALID_TYPE,
|
|
127
|
+
path: path,
|
|
128
|
+
expected: @kind,
|
|
129
|
+
received: "boolean"
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# float32 range check
|
|
135
|
+
if @kind == "float32"
|
|
136
|
+
fval = value.to_f
|
|
137
|
+
if fval.abs > FLOAT32_MAX && !fval.infinite?
|
|
138
|
+
issues << ValidationIssue.new(
|
|
139
|
+
code: IssueCodes::TOO_LARGE,
|
|
140
|
+
path: path,
|
|
141
|
+
expected: "float32",
|
|
142
|
+
received: value.to_s
|
|
143
|
+
)
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
validate_numeric_constraints(value, path, issues)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_numeric_constraints(value, path, issues)
|
|
152
|
+
if @constraints["min"] && value < @constraints["min"]
|
|
153
|
+
issues << ValidationIssue.new(
|
|
154
|
+
code: IssueCodes::TOO_SMALL,
|
|
155
|
+
path: path,
|
|
156
|
+
expected: @constraints["min"].to_s,
|
|
157
|
+
received: value.to_s
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if @constraints["max"] && value > @constraints["max"]
|
|
162
|
+
issues << ValidationIssue.new(
|
|
163
|
+
code: IssueCodes::TOO_LARGE,
|
|
164
|
+
path: path,
|
|
165
|
+
expected: @constraints["max"].to_s,
|
|
166
|
+
received: value.to_s
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if @constraints["exclusiveMin"] && value <= @constraints["exclusiveMin"]
|
|
171
|
+
issues << ValidationIssue.new(
|
|
172
|
+
code: IssueCodes::TOO_SMALL,
|
|
173
|
+
path: path,
|
|
174
|
+
expected: @constraints["exclusiveMin"].to_s,
|
|
175
|
+
received: value.to_s
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
if @constraints["exclusiveMax"] && value >= @constraints["exclusiveMax"]
|
|
180
|
+
issues << ValidationIssue.new(
|
|
181
|
+
code: IssueCodes::TOO_LARGE,
|
|
182
|
+
path: path,
|
|
183
|
+
expected: @constraints["exclusiveMax"].to_s,
|
|
184
|
+
received: value.to_s
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if @constraints["multipleOf"]
|
|
189
|
+
divisor = @constraints["multipleOf"]
|
|
190
|
+
remainder = value.to_f % divisor.to_f
|
|
191
|
+
unless remainder.abs < 1e-10 || (divisor.to_f - remainder.abs).abs < 1e-10
|
|
192
|
+
issues << ValidationIssue.new(
|
|
193
|
+
code: IssueCodes::INVALID_NUMBER,
|
|
194
|
+
path: path,
|
|
195
|
+
expected: divisor.to_s,
|
|
196
|
+
received: value.to_s
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def dup_with(**overrides)
|
|
203
|
+
attrs = {
|
|
204
|
+
kind: @kind,
|
|
205
|
+
constraints: @constraints,
|
|
206
|
+
coerce_config: @coerce_config,
|
|
207
|
+
default_value: @default_value,
|
|
208
|
+
has_default: @has_default,
|
|
209
|
+
custom_validators: @custom_validators
|
|
210
|
+
}.merge(overrides)
|
|
211
|
+
self.class.new(**attrs)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class ObjectSchema < Schema
|
|
5
|
+
attr_reader :properties, :required_keys, :unknown_keys
|
|
6
|
+
|
|
7
|
+
def initialize(properties:, required: [], unknown_keys: "reject", **kwargs)
|
|
8
|
+
@properties = properties.freeze
|
|
9
|
+
@required_keys = required.freeze
|
|
10
|
+
@unknown_keys = unknown_keys
|
|
11
|
+
super(kind: "object", **kwargs)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def strict
|
|
15
|
+
dup_with(unknown_keys: "reject")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def strip_unknown
|
|
19
|
+
dup_with(unknown_keys: "strip")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def allow_unknown
|
|
23
|
+
dup_with(unknown_keys: "allow")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_node
|
|
27
|
+
node = super
|
|
28
|
+
props = {}
|
|
29
|
+
@properties.each do |k, v|
|
|
30
|
+
props[k] = v.to_node
|
|
31
|
+
end
|
|
32
|
+
node["properties"] = props
|
|
33
|
+
node["required"] = @required_keys
|
|
34
|
+
node["unknownKeys"] = @unknown_keys
|
|
35
|
+
node
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def safe_parse(input, path: [], context: nil)
|
|
39
|
+
context ||= ValidationContext.new
|
|
40
|
+
issues = []
|
|
41
|
+
|
|
42
|
+
unless input.is_a?(Hash)
|
|
43
|
+
issues << ValidationIssue.new(
|
|
44
|
+
code: IssueCodes::INVALID_TYPE,
|
|
45
|
+
path: path,
|
|
46
|
+
expected: "object",
|
|
47
|
+
received: Schema.type_name(input)
|
|
48
|
+
)
|
|
49
|
+
return ParseResult.new(value: nil, issues: issues)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
output = {}
|
|
53
|
+
|
|
54
|
+
# Check required fields
|
|
55
|
+
@required_keys.each do |key|
|
|
56
|
+
unless input.key?(key)
|
|
57
|
+
prop_schema = @properties[key]
|
|
58
|
+
expected = prop_schema ? prop_schema.kind : "unknown"
|
|
59
|
+
issues << ValidationIssue.new(
|
|
60
|
+
code: IssueCodes::REQUIRED,
|
|
61
|
+
path: path + [key],
|
|
62
|
+
expected: expected,
|
|
63
|
+
received: "undefined"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Validate known properties
|
|
69
|
+
@properties.each do |key, prop_schema|
|
|
70
|
+
if input.key?(key)
|
|
71
|
+
# Value is present
|
|
72
|
+
val = input[key]
|
|
73
|
+
|
|
74
|
+
# Handle default on property schema (value is present, don't apply default)
|
|
75
|
+
result = prop_schema.safe_parse(val, path: path + [key], context: context)
|
|
76
|
+
if result.success?
|
|
77
|
+
output[key] = result.value
|
|
78
|
+
else
|
|
79
|
+
issues.concat(result.issues)
|
|
80
|
+
end
|
|
81
|
+
elsif prop_schema.has_default
|
|
82
|
+
# Apply default
|
|
83
|
+
default_val = prop_schema.default_value
|
|
84
|
+
# Validate the default value
|
|
85
|
+
result = prop_schema.safe_parse(default_val, path: path + [key], context: context)
|
|
86
|
+
if result.success?
|
|
87
|
+
output[key] = result.value
|
|
88
|
+
else
|
|
89
|
+
# Default is invalid
|
|
90
|
+
issues << ValidationIssue.new(
|
|
91
|
+
code: IssueCodes::DEFAULT_INVALID,
|
|
92
|
+
path: path + [key],
|
|
93
|
+
expected: result.issues.first&.expected || prop_schema.kind,
|
|
94
|
+
received: default_val.to_s
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
elsif prop_schema.is_a?(OptionalSchema)
|
|
98
|
+
# Optional field absent - OK
|
|
99
|
+
elsif !@required_keys.include?(key)
|
|
100
|
+
# Non-required field absent without default - OK
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Handle unknown keys
|
|
105
|
+
unknown = input.keys - @properties.keys
|
|
106
|
+
case @unknown_keys
|
|
107
|
+
when "reject"
|
|
108
|
+
unknown.each do |key|
|
|
109
|
+
issues << ValidationIssue.new(
|
|
110
|
+
code: IssueCodes::UNKNOWN_KEY,
|
|
111
|
+
path: path + [key],
|
|
112
|
+
expected: "undefined",
|
|
113
|
+
received: key
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
when "strip"
|
|
117
|
+
# Just don't include them
|
|
118
|
+
when "allow"
|
|
119
|
+
unknown.each { |key| output[key] = input[key] }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if issues.empty?
|
|
123
|
+
ParseResult.new(value: output, issues: [])
|
|
124
|
+
else
|
|
125
|
+
ParseResult.new(value: nil, issues: issues)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
protected
|
|
130
|
+
|
|
131
|
+
def validate(value, path, issues, context)
|
|
132
|
+
# Handled in safe_parse override
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def dup_with(**overrides)
|
|
136
|
+
attrs = {
|
|
137
|
+
properties: @properties,
|
|
138
|
+
required: @required_keys,
|
|
139
|
+
unknown_keys: @unknown_keys,
|
|
140
|
+
kind: @kind,
|
|
141
|
+
constraints: @constraints,
|
|
142
|
+
coerce_config: @coerce_config,
|
|
143
|
+
default_value: @default_value,
|
|
144
|
+
has_default: @has_default,
|
|
145
|
+
custom_validators: @custom_validators
|
|
146
|
+
}.merge(overrides)
|
|
147
|
+
self.class.new(**attrs)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class OptionalSchema < Schema
|
|
5
|
+
attr_reader :inner_schema
|
|
6
|
+
|
|
7
|
+
def initialize(schema:, **kwargs)
|
|
8
|
+
@inner_schema = schema
|
|
9
|
+
super(kind: "optional", **kwargs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_node
|
|
13
|
+
node = super
|
|
14
|
+
node["schema"] = @inner_schema.to_node
|
|
15
|
+
node
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def safe_parse(input, path: [], context: nil)
|
|
19
|
+
# When used in objects, absence is handled by ObjectSchema
|
|
20
|
+
# If called directly with a value, delegate to inner schema
|
|
21
|
+
context ||= ValidationContext.new
|
|
22
|
+
@inner_schema.safe_parse(input, path: path, context: context)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
protected
|
|
26
|
+
|
|
27
|
+
def validate(value, path, issues, context)
|
|
28
|
+
# Delegate to inner schema
|
|
29
|
+
result = @inner_schema.safe_parse(value, path: path, context: context)
|
|
30
|
+
issues.concat(result.issues) if result.failure?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def dup_with(**overrides)
|
|
34
|
+
schema = overrides.delete(:schema) || @inner_schema
|
|
35
|
+
attrs = {
|
|
36
|
+
schema: schema,
|
|
37
|
+
kind: @kind,
|
|
38
|
+
constraints: @constraints,
|
|
39
|
+
coerce_config: @coerce_config,
|
|
40
|
+
default_value: @default_value,
|
|
41
|
+
has_default: @has_default,
|
|
42
|
+
custom_validators: @custom_validators
|
|
43
|
+
}.merge(overrides)
|
|
44
|
+
self.class.new(**attrs)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class RecordSchema < Schema
|
|
5
|
+
attr_reader :values_schema
|
|
6
|
+
|
|
7
|
+
def initialize(values:, **kwargs)
|
|
8
|
+
@values_schema = values
|
|
9
|
+
super(kind: "record", **kwargs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_node
|
|
13
|
+
node = super
|
|
14
|
+
node["values"] = @values_schema.to_node
|
|
15
|
+
node
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
protected
|
|
19
|
+
|
|
20
|
+
def validate(value, path, issues, context)
|
|
21
|
+
unless value.is_a?(Hash)
|
|
22
|
+
issues << ValidationIssue.new(
|
|
23
|
+
code: IssueCodes::INVALID_TYPE,
|
|
24
|
+
path: path,
|
|
25
|
+
expected: "record",
|
|
26
|
+
received: Schema.type_name(value)
|
|
27
|
+
)
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
value.each do |k, v|
|
|
32
|
+
result = @values_schema.safe_parse(v, path: path + [k], context: context)
|
|
33
|
+
issues.concat(result.issues) if result.failure?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dup_with(**overrides)
|
|
38
|
+
values = overrides.delete(:values) || @values_schema
|
|
39
|
+
attrs = {
|
|
40
|
+
values: values,
|
|
41
|
+
kind: @kind,
|
|
42
|
+
constraints: @constraints,
|
|
43
|
+
coerce_config: @coerce_config,
|
|
44
|
+
default_value: @default_value,
|
|
45
|
+
has_default: @has_default,
|
|
46
|
+
custom_validators: @custom_validators
|
|
47
|
+
}.merge(overrides)
|
|
48
|
+
self.class.new(**attrs)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|