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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +339 -0
  3. data/lib/anyvali/anyvali_document.rb +43 -0
  4. data/lib/anyvali/format/validators.rb +89 -0
  5. data/lib/anyvali/interchange/exporter.rb +40 -0
  6. data/lib/anyvali/interchange/importer.rb +201 -0
  7. data/lib/anyvali/issue_codes.rb +20 -0
  8. data/lib/anyvali/parse/coercion.rb +93 -0
  9. data/lib/anyvali/parse/coercion_config.rb +19 -0
  10. data/lib/anyvali/parse/defaults.rb +21 -0
  11. data/lib/anyvali/parse_result.rb +21 -0
  12. data/lib/anyvali/schema.rb +132 -0
  13. data/lib/anyvali/schemas/any_schema.rb +15 -0
  14. data/lib/anyvali/schemas/array_schema.rb +77 -0
  15. data/lib/anyvali/schemas/bool_schema.rb +22 -0
  16. data/lib/anyvali/schemas/enum_schema.rb +50 -0
  17. data/lib/anyvali/schemas/int_schema.rb +9 -0
  18. data/lib/anyvali/schemas/intersection_schema.rb +65 -0
  19. data/lib/anyvali/schemas/literal_schema.rb +52 -0
  20. data/lib/anyvali/schemas/never_schema.rb +20 -0
  21. data/lib/anyvali/schemas/null_schema.rb +22 -0
  22. data/lib/anyvali/schemas/nullable_schema.rb +40 -0
  23. data/lib/anyvali/schemas/number_schema.rb +214 -0
  24. data/lib/anyvali/schemas/object_schema.rb +150 -0
  25. data/lib/anyvali/schemas/optional_schema.rb +47 -0
  26. data/lib/anyvali/schemas/record_schema.rb +51 -0
  27. data/lib/anyvali/schemas/ref_schema.rb +58 -0
  28. data/lib/anyvali/schemas/string_schema.rb +113 -0
  29. data/lib/anyvali/schemas/tuple_schema.rb +72 -0
  30. data/lib/anyvali/schemas/union_schema.rb +50 -0
  31. data/lib/anyvali/schemas/unknown_schema.rb +15 -0
  32. data/lib/anyvali/validation_context.rb +11 -0
  33. data/lib/anyvali/validation_error.rb +13 -0
  34. data/lib/anyvali/validation_issue.rb +45 -0
  35. data/lib/anyvali.rb +176 -0
  36. 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