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,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AnyVali
|
|
6
|
+
module Interchange
|
|
7
|
+
module Importer
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def import(doc)
|
|
11
|
+
doc = JSON.parse(doc) if doc.is_a?(String)
|
|
12
|
+
|
|
13
|
+
# Build definitions first (needed for refs)
|
|
14
|
+
definitions = {}
|
|
15
|
+
if doc["definitions"]
|
|
16
|
+
doc["definitions"].each do |name, node|
|
|
17
|
+
definitions[name] = node_to_schema(node)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
root_schema = node_to_schema(doc["root"])
|
|
22
|
+
context = ValidationContext.new(definitions: definitions)
|
|
23
|
+
[root_schema, context, definitions]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def import_schema(doc)
|
|
27
|
+
schema, context, definitions = import(doc)
|
|
28
|
+
schema
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def node_to_schema(node)
|
|
32
|
+
kind = node["kind"]
|
|
33
|
+
|
|
34
|
+
case kind
|
|
35
|
+
when "string"
|
|
36
|
+
build_string(node)
|
|
37
|
+
when "number", "float32", "float64"
|
|
38
|
+
build_number(node, kind)
|
|
39
|
+
when "int", "int8", "int16", "int32", "int64",
|
|
40
|
+
"uint8", "uint16", "uint32", "uint64"
|
|
41
|
+
build_int(node, kind)
|
|
42
|
+
when "bool"
|
|
43
|
+
build_bool(node)
|
|
44
|
+
when "null"
|
|
45
|
+
NullSchema.new
|
|
46
|
+
when "any"
|
|
47
|
+
AnySchema.new
|
|
48
|
+
when "unknown"
|
|
49
|
+
UnknownSchema.new
|
|
50
|
+
when "never"
|
|
51
|
+
NeverSchema.new
|
|
52
|
+
when "literal"
|
|
53
|
+
LiteralSchema.new(value: node["value"])
|
|
54
|
+
when "enum"
|
|
55
|
+
EnumSchema.new(values: node["values"])
|
|
56
|
+
when "array"
|
|
57
|
+
build_array(node)
|
|
58
|
+
when "tuple"
|
|
59
|
+
build_tuple(node)
|
|
60
|
+
when "object"
|
|
61
|
+
build_object(node)
|
|
62
|
+
when "record"
|
|
63
|
+
build_record(node)
|
|
64
|
+
when "union"
|
|
65
|
+
build_union(node)
|
|
66
|
+
when "intersection"
|
|
67
|
+
build_intersection(node)
|
|
68
|
+
when "optional"
|
|
69
|
+
OptionalSchema.new(schema: node_to_schema(node["schema"]))
|
|
70
|
+
when "nullable"
|
|
71
|
+
build_nullable(node)
|
|
72
|
+
when "ref"
|
|
73
|
+
RefSchema.new(ref: node["ref"])
|
|
74
|
+
else
|
|
75
|
+
raise ValidationError, [
|
|
76
|
+
ValidationIssue.new(
|
|
77
|
+
code: IssueCodes::UNSUPPORTED_SCHEMA_KIND,
|
|
78
|
+
expected: "known schema kind",
|
|
79
|
+
received: kind.to_s
|
|
80
|
+
)
|
|
81
|
+
]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_string(node)
|
|
86
|
+
constraints = {}
|
|
87
|
+
%w[minLength maxLength pattern startsWith endsWith includes format].each do |k|
|
|
88
|
+
constraints[k] = node[k] if node.key?(k)
|
|
89
|
+
end
|
|
90
|
+
coerce = node["coerce"]
|
|
91
|
+
default_val = node["default"]
|
|
92
|
+
has_default = node.key?("default")
|
|
93
|
+
StringSchema.new(
|
|
94
|
+
constraints: constraints,
|
|
95
|
+
coerce_config: coerce,
|
|
96
|
+
default_value: default_val,
|
|
97
|
+
has_default: has_default
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_number(node, kind)
|
|
102
|
+
constraints = {}
|
|
103
|
+
%w[min max exclusiveMin exclusiveMax multipleOf].each do |k|
|
|
104
|
+
constraints[k] = node[k] if node.key?(k)
|
|
105
|
+
end
|
|
106
|
+
coerce = node["coerce"]
|
|
107
|
+
default_val = node["default"]
|
|
108
|
+
has_default = node.key?("default")
|
|
109
|
+
NumberSchema.new(
|
|
110
|
+
kind: kind,
|
|
111
|
+
constraints: constraints,
|
|
112
|
+
coerce_config: coerce,
|
|
113
|
+
default_value: default_val,
|
|
114
|
+
has_default: has_default
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_int(node, kind)
|
|
119
|
+
constraints = {}
|
|
120
|
+
%w[min max exclusiveMin exclusiveMax multipleOf].each do |k|
|
|
121
|
+
constraints[k] = node[k] if node.key?(k)
|
|
122
|
+
end
|
|
123
|
+
coerce = node["coerce"]
|
|
124
|
+
default_val = node["default"]
|
|
125
|
+
has_default = node.key?("default")
|
|
126
|
+
IntSchema.new(
|
|
127
|
+
kind: kind,
|
|
128
|
+
constraints: constraints,
|
|
129
|
+
coerce_config: coerce,
|
|
130
|
+
default_value: default_val,
|
|
131
|
+
has_default: has_default
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def build_bool(node)
|
|
136
|
+
coerce = node["coerce"]
|
|
137
|
+
default_val = node["default"]
|
|
138
|
+
has_default = node.key?("default")
|
|
139
|
+
BoolSchema.new(
|
|
140
|
+
coerce_config: coerce,
|
|
141
|
+
default_value: default_val,
|
|
142
|
+
has_default: has_default
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build_array(node)
|
|
147
|
+
items = node_to_schema(node["items"])
|
|
148
|
+
constraints = {}
|
|
149
|
+
%w[minItems maxItems].each do |k|
|
|
150
|
+
constraints[k] = node[k] if node.key?(k)
|
|
151
|
+
end
|
|
152
|
+
ArraySchema.new(items: items, constraints: constraints)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def build_tuple(node)
|
|
156
|
+
elements = node["elements"].map { |e| node_to_schema(e) }
|
|
157
|
+
TupleSchema.new(elements: elements)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def build_object(node)
|
|
161
|
+
props = {}
|
|
162
|
+
(node["properties"] || {}).each do |k, v|
|
|
163
|
+
props[k] = node_to_schema(v)
|
|
164
|
+
end
|
|
165
|
+
required = node["required"] || []
|
|
166
|
+
unknown_keys = node["unknownKeys"] || "reject"
|
|
167
|
+
ObjectSchema.new(
|
|
168
|
+
properties: props,
|
|
169
|
+
required: required,
|
|
170
|
+
unknown_keys: unknown_keys
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def build_record(node)
|
|
175
|
+
values = node_to_schema(node["values"])
|
|
176
|
+
RecordSchema.new(values: values)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_union(node)
|
|
180
|
+
variants = node["variants"].map { |v| node_to_schema(v) }
|
|
181
|
+
UnionSchema.new(variants: variants)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_intersection(node)
|
|
185
|
+
all_of = node["allOf"].map { |v| node_to_schema(v) }
|
|
186
|
+
IntersectionSchema.new(all_of: all_of)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_nullable(node)
|
|
190
|
+
inner = node_to_schema(node["schema"])
|
|
191
|
+
default_val = node["default"]
|
|
192
|
+
has_default = node.key?("default")
|
|
193
|
+
NullableSchema.new(
|
|
194
|
+
schema: inner,
|
|
195
|
+
default_value: default_val,
|
|
196
|
+
has_default: has_default
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
module IssueCodes
|
|
5
|
+
INVALID_TYPE = "invalid_type"
|
|
6
|
+
REQUIRED = "required"
|
|
7
|
+
UNKNOWN_KEY = "unknown_key"
|
|
8
|
+
TOO_SMALL = "too_small"
|
|
9
|
+
TOO_LARGE = "too_large"
|
|
10
|
+
INVALID_STRING = "invalid_string"
|
|
11
|
+
INVALID_NUMBER = "invalid_number"
|
|
12
|
+
INVALID_LITERAL = "invalid_literal"
|
|
13
|
+
INVALID_UNION = "invalid_union"
|
|
14
|
+
CUSTOM_VALIDATION_NOT_PORTABLE = "custom_validation_not_portable"
|
|
15
|
+
UNSUPPORTED_EXTENSION = "unsupported_extension"
|
|
16
|
+
UNSUPPORTED_SCHEMA_KIND = "unsupported_schema_kind"
|
|
17
|
+
COERCION_FAILED = "coercion_failed"
|
|
18
|
+
DEFAULT_INVALID = "default_invalid"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
module Coercion
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def apply(value, config, kind)
|
|
8
|
+
configs = config.is_a?(Array) ? config : [config]
|
|
9
|
+
current = value
|
|
10
|
+
|
|
11
|
+
configs.each do |c|
|
|
12
|
+
result = apply_single(current, c, kind)
|
|
13
|
+
return result unless result[:success]
|
|
14
|
+
current = result[:value]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
{ success: true, value: current }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def apply_single(value, config, kind)
|
|
21
|
+
case config
|
|
22
|
+
when "string->int"
|
|
23
|
+
coerce_string_to_int(value)
|
|
24
|
+
when "string->number"
|
|
25
|
+
coerce_string_to_number(value)
|
|
26
|
+
when "string->bool"
|
|
27
|
+
coerce_string_to_bool(value)
|
|
28
|
+
when "trim"
|
|
29
|
+
coerce_trim(value)
|
|
30
|
+
when "lower"
|
|
31
|
+
coerce_lower(value)
|
|
32
|
+
when "upper"
|
|
33
|
+
coerce_upper(value)
|
|
34
|
+
else
|
|
35
|
+
{ success: false, value: value }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def coerce_string_to_int(value)
|
|
40
|
+
return { success: true, value: value } if value.is_a?(Integer)
|
|
41
|
+
return { success: false, value: value } unless value.is_a?(String)
|
|
42
|
+
|
|
43
|
+
stripped = value.strip
|
|
44
|
+
if stripped.match?(/\A-?\d+\z/)
|
|
45
|
+
{ success: true, value: stripped.to_i }
|
|
46
|
+
else
|
|
47
|
+
{ success: false, value: value }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def coerce_string_to_number(value)
|
|
52
|
+
return { success: true, value: value } if value.is_a?(Numeric)
|
|
53
|
+
return { success: false, value: value } unless value.is_a?(String)
|
|
54
|
+
|
|
55
|
+
stripped = value.strip
|
|
56
|
+
begin
|
|
57
|
+
f = Float(stripped)
|
|
58
|
+
{ success: true, value: f }
|
|
59
|
+
rescue ArgumentError
|
|
60
|
+
{ success: false, value: value }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def coerce_string_to_bool(value)
|
|
65
|
+
return { success: true, value: value } if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
66
|
+
return { success: false, value: value } unless value.is_a?(String)
|
|
67
|
+
|
|
68
|
+
case value.strip.downcase
|
|
69
|
+
when "true", "1"
|
|
70
|
+
{ success: true, value: true }
|
|
71
|
+
when "false", "0"
|
|
72
|
+
{ success: true, value: false }
|
|
73
|
+
else
|
|
74
|
+
{ success: false, value: value }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def coerce_trim(value)
|
|
79
|
+
return { success: true, value: value } unless value.is_a?(String)
|
|
80
|
+
{ success: true, value: value.strip }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def coerce_lower(value)
|
|
84
|
+
return { success: true, value: value } unless value.is_a?(String)
|
|
85
|
+
{ success: true, value: value.downcase }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def coerce_upper(value)
|
|
89
|
+
return { success: true, value: value } unless value.is_a?(String)
|
|
90
|
+
{ success: true, value: value.upcase }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
module CoercionConfig
|
|
5
|
+
PORTABLE_COERCIONS = %w[
|
|
6
|
+
string->int
|
|
7
|
+
string->number
|
|
8
|
+
string->bool
|
|
9
|
+
trim
|
|
10
|
+
lower
|
|
11
|
+
upper
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def self.portable?(config)
|
|
15
|
+
configs = config.is_a?(Array) ? config : [config]
|
|
16
|
+
configs.all? { |c| PORTABLE_COERCIONS.include?(c) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
module Defaults
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Check if a default value is JSON-serializable (portable)
|
|
8
|
+
def portable?(value)
|
|
9
|
+
case value
|
|
10
|
+
when NilClass, TrueClass, FalseClass, Integer, Float, String
|
|
11
|
+
true
|
|
12
|
+
when Array
|
|
13
|
+
value.all? { |v| portable?(v) }
|
|
14
|
+
when Hash
|
|
15
|
+
value.all? { |k, v| k.is_a?(String) && portable?(v) }
|
|
16
|
+
else
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class ParseResult
|
|
5
|
+
attr_reader :value, :issues
|
|
6
|
+
|
|
7
|
+
def initialize(value:, issues:)
|
|
8
|
+
@value = value
|
|
9
|
+
@issues = issues.freeze
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def success?
|
|
14
|
+
@issues.empty?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def failure?
|
|
18
|
+
!success?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class Schema
|
|
5
|
+
attr_reader :kind, :constraints, :coerce_config, :default_value, :has_default,
|
|
6
|
+
:custom_validators
|
|
7
|
+
|
|
8
|
+
def initialize(kind:, constraints: {}, coerce_config: nil, default_value: nil, has_default: false, custom_validators: [])
|
|
9
|
+
@kind = kind
|
|
10
|
+
@constraints = constraints.freeze
|
|
11
|
+
@coerce_config = coerce_config
|
|
12
|
+
@default_value = default_value
|
|
13
|
+
@has_default = has_default
|
|
14
|
+
@custom_validators = custom_validators.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse(input)
|
|
18
|
+
result = safe_parse(input)
|
|
19
|
+
raise ValidationError, result.issues if result.failure?
|
|
20
|
+
result.value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def safe_parse(input, path: [], context: nil)
|
|
24
|
+
context ||= ValidationContext.new
|
|
25
|
+
value = input
|
|
26
|
+
issues = []
|
|
27
|
+
|
|
28
|
+
# Step 1: Coercion (if present and configured)
|
|
29
|
+
if @coerce_config && !value.nil?
|
|
30
|
+
coerced = Coercion.apply(value, @coerce_config, @kind)
|
|
31
|
+
if coerced[:success]
|
|
32
|
+
value = coerced[:value]
|
|
33
|
+
else
|
|
34
|
+
issues << ValidationIssue.new(
|
|
35
|
+
code: IssueCodes::COERCION_FAILED,
|
|
36
|
+
path: path,
|
|
37
|
+
expected: @kind,
|
|
38
|
+
received: value.is_a?(String) ? value : value.to_s
|
|
39
|
+
)
|
|
40
|
+
return ParseResult.new(value: nil, issues: issues)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Step 2: Validate
|
|
45
|
+
validate(value, path, issues, context)
|
|
46
|
+
|
|
47
|
+
# Step 3: Custom validators
|
|
48
|
+
if issues.empty? && !@custom_validators.empty?
|
|
49
|
+
@custom_validators.each do |validator|
|
|
50
|
+
validator_issues = validator.call(value, path)
|
|
51
|
+
issues.concat(validator_issues) if validator_issues
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if issues.empty?
|
|
56
|
+
ParseResult.new(value: value, issues: [])
|
|
57
|
+
else
|
|
58
|
+
ParseResult.new(value: nil, issues: issues)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def default(value)
|
|
63
|
+
dup_with(default_value: value, has_default: true)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def coerce(config)
|
|
67
|
+
dup_with(coerce_config: config)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def refine(&block)
|
|
71
|
+
dup_with(custom_validators: @custom_validators + [block])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def portable?
|
|
75
|
+
@custom_validators.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def export(mode: :portable)
|
|
79
|
+
if mode == :portable && !portable?
|
|
80
|
+
raise ValidationError, [
|
|
81
|
+
ValidationIssue.new(
|
|
82
|
+
code: IssueCodes::CUSTOM_VALIDATION_NOT_PORTABLE,
|
|
83
|
+
expected: "portable schema",
|
|
84
|
+
received: "schema with custom validators"
|
|
85
|
+
)
|
|
86
|
+
]
|
|
87
|
+
end
|
|
88
|
+
doc = AnyValiDocument.new(root: self)
|
|
89
|
+
doc.to_h
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def to_node
|
|
93
|
+
node = { "kind" => @kind }
|
|
94
|
+
@constraints.each { |k, v| node[k] = v }
|
|
95
|
+
node["coerce"] = @coerce_config if @coerce_config
|
|
96
|
+
node["default"] = @default_value if @has_default
|
|
97
|
+
node
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
protected
|
|
101
|
+
|
|
102
|
+
def validate(value, path, issues, context)
|
|
103
|
+
raise NotImplementedError, "Subclasses must implement validate"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def dup_with(**overrides)
|
|
107
|
+
attrs = {
|
|
108
|
+
kind: @kind,
|
|
109
|
+
constraints: @constraints,
|
|
110
|
+
coerce_config: @coerce_config,
|
|
111
|
+
default_value: @default_value,
|
|
112
|
+
has_default: @has_default,
|
|
113
|
+
custom_validators: @custom_validators
|
|
114
|
+
}.merge(overrides)
|
|
115
|
+
self.class.new(**attrs)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Helper to get the JSON type name for a Ruby value
|
|
119
|
+
def self.type_name(value)
|
|
120
|
+
case value
|
|
121
|
+
when NilClass then "null"
|
|
122
|
+
when TrueClass, FalseClass then "boolean"
|
|
123
|
+
when Integer then "number"
|
|
124
|
+
when Float then "number"
|
|
125
|
+
when String then "string"
|
|
126
|
+
when Array then "array"
|
|
127
|
+
when Hash then "object"
|
|
128
|
+
else value.class.name.downcase
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class AnySchema < Schema
|
|
5
|
+
def initialize(**kwargs)
|
|
6
|
+
super(kind: "any", **kwargs)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def validate(value, path, issues, context)
|
|
12
|
+
# any accepts everything
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class ArraySchema < Schema
|
|
5
|
+
attr_reader :items_schema
|
|
6
|
+
|
|
7
|
+
def initialize(items:, constraints: {}, **kwargs)
|
|
8
|
+
@items_schema = items
|
|
9
|
+
super(kind: "array", constraints: constraints, **kwargs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def min_items(n)
|
|
13
|
+
dup_with(constraints: @constraints.merge("minItems" => n))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def max_items(n)
|
|
17
|
+
dup_with(constraints: @constraints.merge("maxItems" => n))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_node
|
|
21
|
+
node = super
|
|
22
|
+
node["items"] = @items_schema.to_node
|
|
23
|
+
node
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
protected
|
|
27
|
+
|
|
28
|
+
def validate(value, path, issues, context)
|
|
29
|
+
unless value.is_a?(Array)
|
|
30
|
+
issues << ValidationIssue.new(
|
|
31
|
+
code: IssueCodes::INVALID_TYPE,
|
|
32
|
+
path: path,
|
|
33
|
+
expected: "array",
|
|
34
|
+
received: Schema.type_name(value)
|
|
35
|
+
)
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if @constraints["minItems"] && value.length < @constraints["minItems"]
|
|
40
|
+
issues << ValidationIssue.new(
|
|
41
|
+
code: IssueCodes::TOO_SMALL,
|
|
42
|
+
path: path,
|
|
43
|
+
expected: @constraints["minItems"].to_s,
|
|
44
|
+
received: value.length.to_s
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if @constraints["maxItems"] && value.length > @constraints["maxItems"]
|
|
49
|
+
issues << ValidationIssue.new(
|
|
50
|
+
code: IssueCodes::TOO_LARGE,
|
|
51
|
+
path: path,
|
|
52
|
+
expected: @constraints["maxItems"].to_s,
|
|
53
|
+
received: value.length.to_s
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
value.each_with_index do |item, i|
|
|
58
|
+
result = @items_schema.safe_parse(item, path: path + [i], context: context)
|
|
59
|
+
issues.concat(result.issues) if result.failure?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dup_with(**overrides)
|
|
64
|
+
items = overrides.delete(:items) || @items_schema
|
|
65
|
+
attrs = {
|
|
66
|
+
items: items,
|
|
67
|
+
kind: @kind,
|
|
68
|
+
constraints: @constraints,
|
|
69
|
+
coerce_config: @coerce_config,
|
|
70
|
+
default_value: @default_value,
|
|
71
|
+
has_default: @has_default,
|
|
72
|
+
custom_validators: @custom_validators
|
|
73
|
+
}.merge(overrides)
|
|
74
|
+
self.class.new(**attrs)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class BoolSchema < Schema
|
|
5
|
+
def initialize(**kwargs)
|
|
6
|
+
super(kind: "bool", **kwargs)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def validate(value, path, issues, context)
|
|
12
|
+
unless value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
13
|
+
issues << ValidationIssue.new(
|
|
14
|
+
code: IssueCodes::INVALID_TYPE,
|
|
15
|
+
path: path,
|
|
16
|
+
expected: "bool",
|
|
17
|
+
received: Schema.type_name(value)
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnyVali
|
|
4
|
+
class EnumSchema < Schema
|
|
5
|
+
attr_reader :values
|
|
6
|
+
|
|
7
|
+
def initialize(values:, **kwargs)
|
|
8
|
+
@values = values.freeze
|
|
9
|
+
super(kind: "enum", **kwargs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_node
|
|
13
|
+
node = super
|
|
14
|
+
node["values"] = @values
|
|
15
|
+
node
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
protected
|
|
19
|
+
|
|
20
|
+
def validate(value, path, issues, context)
|
|
21
|
+
match = @values.any? { |v| v.class == value.class && v == value }
|
|
22
|
+
# Special nil handling
|
|
23
|
+
match ||= @values.include?(nil) && value.nil?
|
|
24
|
+
|
|
25
|
+
unless match
|
|
26
|
+
enum_str = @values.map(&:to_s).join(",")
|
|
27
|
+
received_str = value.nil? ? "null" : value.to_s
|
|
28
|
+
issues << ValidationIssue.new(
|
|
29
|
+
code: IssueCodes::INVALID_TYPE,
|
|
30
|
+
path: path,
|
|
31
|
+
expected: "enum(#{enum_str})",
|
|
32
|
+
received: received_str
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dup_with(**overrides)
|
|
38
|
+
attrs = {
|
|
39
|
+
values: @values,
|
|
40
|
+
kind: @kind,
|
|
41
|
+
constraints: @constraints,
|
|
42
|
+
coerce_config: @coerce_config,
|
|
43
|
+
default_value: @default_value,
|
|
44
|
+
has_default: @has_default,
|
|
45
|
+
custom_validators: @custom_validators
|
|
46
|
+
}.merge(overrides)
|
|
47
|
+
self.class.new(**attrs)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|