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,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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyVali
4
+ class IntSchema < NumberSchema
5
+ def initialize(kind: "int", **kwargs)
6
+ super(kind: kind, **kwargs)
7
+ end
8
+ end
9
+ end