validrb 0.5.0
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/CHANGELOG.md +99 -0
- data/CLAUDE.md +434 -0
- data/LICENSE +21 -0
- data/README.md +654 -0
- data/Rakefile +10 -0
- data/lib/validrb/constraints/base.rb +59 -0
- data/lib/validrb/constraints/enum.rb +33 -0
- data/lib/validrb/constraints/format.rb +63 -0
- data/lib/validrb/constraints/length.rb +72 -0
- data/lib/validrb/constraints/max.rb +43 -0
- data/lib/validrb/constraints/min.rb +43 -0
- data/lib/validrb/context.rb +41 -0
- data/lib/validrb/custom_type.rb +95 -0
- data/lib/validrb/errors.rb +122 -0
- data/lib/validrb/field.rb +346 -0
- data/lib/validrb/i18n.rb +88 -0
- data/lib/validrb/introspection.rb +206 -0
- data/lib/validrb/openapi.rb +642 -0
- data/lib/validrb/result.rb +89 -0
- data/lib/validrb/schema.rb +303 -0
- data/lib/validrb/serializer.rb +113 -0
- data/lib/validrb/types/array.rb +91 -0
- data/lib/validrb/types/base.rb +90 -0
- data/lib/validrb/types/boolean.rb +37 -0
- data/lib/validrb/types/date.rb +70 -0
- data/lib/validrb/types/datetime.rb +71 -0
- data/lib/validrb/types/decimal.rb +57 -0
- data/lib/validrb/types/discriminated_union.rb +74 -0
- data/lib/validrb/types/float.rb +46 -0
- data/lib/validrb/types/integer.rb +53 -0
- data/lib/validrb/types/literal.rb +43 -0
- data/lib/validrb/types/object.rb +52 -0
- data/lib/validrb/types/string.rb +29 -0
- data/lib/validrb/types/time.rb +69 -0
- data/lib/validrb/types/union.rb +75 -0
- data/lib/validrb/version.rb +5 -0
- data/lib/validrb.rb +55 -0
- data/validrb.gemspec +43 -0
- metadata +91 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
# Represents a successful validation result
|
|
5
|
+
class Success
|
|
6
|
+
attr_reader :data
|
|
7
|
+
|
|
8
|
+
def initialize(data)
|
|
9
|
+
@data = data.freeze
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def success?
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def failure?
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def errors
|
|
22
|
+
ErrorCollection.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def value_or(_default = nil)
|
|
26
|
+
@data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def map
|
|
30
|
+
Success.new(yield(@data))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def flat_map
|
|
34
|
+
yield(@data)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ==(other)
|
|
38
|
+
other.is_a?(Success) && data == other.data
|
|
39
|
+
end
|
|
40
|
+
alias eql? ==
|
|
41
|
+
|
|
42
|
+
def hash
|
|
43
|
+
[self.class, data].hash
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Represents a failed validation result
|
|
48
|
+
class Failure
|
|
49
|
+
attr_reader :errors
|
|
50
|
+
|
|
51
|
+
def initialize(errors)
|
|
52
|
+
@errors = errors.is_a?(ErrorCollection) ? errors : ErrorCollection.new(Array(errors))
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def success?
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def failure?
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def data
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def value_or(default = nil)
|
|
69
|
+
block_given? ? yield(@errors) : default
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def map
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def flat_map
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ==(other)
|
|
81
|
+
other.is_a?(Failure) && errors.to_a == other.errors.to_a
|
|
82
|
+
end
|
|
83
|
+
alias eql? ==
|
|
84
|
+
|
|
85
|
+
def hash
|
|
86
|
+
[self.class, errors.to_a].hash
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
# Schema class with DSL for defining fields
|
|
5
|
+
class Schema
|
|
6
|
+
attr_reader :fields, :validators, :options
|
|
7
|
+
|
|
8
|
+
# Options:
|
|
9
|
+
# strict: true - raise error on unknown keys
|
|
10
|
+
# strip: true - remove unknown keys (default behavior)
|
|
11
|
+
# passthrough: true - keep unknown keys in output
|
|
12
|
+
def initialize(**options, &block)
|
|
13
|
+
@fields = {}
|
|
14
|
+
@validators = []
|
|
15
|
+
@options = normalize_options(options)
|
|
16
|
+
@builder = Builder.new(self)
|
|
17
|
+
@builder.instance_eval(&block) if block_given?
|
|
18
|
+
@fields.freeze
|
|
19
|
+
@validators.freeze
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Parse data and raise ValidationError on failure
|
|
24
|
+
# @param data [Hash] The data to validate
|
|
25
|
+
# @param path_prefix [Array] Path prefix for error messages
|
|
26
|
+
# @param context [Context, Hash, nil] Optional validation context
|
|
27
|
+
def parse(data, path_prefix: [], context: nil)
|
|
28
|
+
result = safe_parse(data, path_prefix: path_prefix, context: context)
|
|
29
|
+
|
|
30
|
+
raise ValidationError, result.errors if result.failure?
|
|
31
|
+
|
|
32
|
+
result.data
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parse data and return Result (Success or Failure)
|
|
36
|
+
# @param data [Hash] The data to validate
|
|
37
|
+
# @param path_prefix [Array] Path prefix for error messages
|
|
38
|
+
# @param context [Context, Hash, nil] Optional validation context
|
|
39
|
+
def safe_parse(data, path_prefix: [], context: nil)
|
|
40
|
+
normalized = normalize_input(data)
|
|
41
|
+
ctx = normalize_context(context)
|
|
42
|
+
errors = []
|
|
43
|
+
result_data = {}
|
|
44
|
+
|
|
45
|
+
# Check for unknown keys if strict mode
|
|
46
|
+
if @options[:strict]
|
|
47
|
+
unknown_keys = normalized.keys - @fields.keys
|
|
48
|
+
unknown_keys.each do |key|
|
|
49
|
+
errors << Error.new(path: path_prefix + [key], message: "is not allowed", code: :unknown_key)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Validate each field
|
|
54
|
+
@fields.each do |name, field|
|
|
55
|
+
value = fetch_value(normalized, name)
|
|
56
|
+
# Pass full data and context for conditional validation (when:/unless:)
|
|
57
|
+
coerced, field_errors = field.call(value, path: path_prefix, data: normalized, context: ctx)
|
|
58
|
+
|
|
59
|
+
if field_errors.empty?
|
|
60
|
+
# Only include in result if value is not nil or field has a value
|
|
61
|
+
# Also skip if field was conditionally skipped (conditional? && value is nil)
|
|
62
|
+
should_include = !(coerced.nil? && field.optional? && !field.has_default?)
|
|
63
|
+
should_include &&= !(coerced.nil? && field.conditional?)
|
|
64
|
+
result_data[name] = coerced if should_include
|
|
65
|
+
else
|
|
66
|
+
errors.concat(field_errors)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Passthrough unknown keys
|
|
71
|
+
if @options[:passthrough]
|
|
72
|
+
unknown_keys = normalized.keys - @fields.keys
|
|
73
|
+
unknown_keys.each do |key|
|
|
74
|
+
result_data[key] = normalized[key]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Run custom validators only if no field errors
|
|
79
|
+
if errors.empty?
|
|
80
|
+
validator_errors = run_validators(result_data, path_prefix, ctx)
|
|
81
|
+
errors.concat(validator_errors)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if errors.empty?
|
|
85
|
+
Success.new(result_data)
|
|
86
|
+
else
|
|
87
|
+
Failure.new(errors)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Add a field to the schema (used by Builder)
|
|
92
|
+
def add_field(field)
|
|
93
|
+
raise ArgumentError, "Field #{field.name} already defined" if @fields.key?(field.name)
|
|
94
|
+
|
|
95
|
+
@fields[field.name] = field
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Add a custom validator (used by Builder)
|
|
99
|
+
def add_validator(validator)
|
|
100
|
+
@validators << validator
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Schema composition methods
|
|
104
|
+
|
|
105
|
+
# Create a new schema extending this one with additional fields
|
|
106
|
+
def extend(**options, &block)
|
|
107
|
+
parent_fields = @fields
|
|
108
|
+
parent_validators = @validators
|
|
109
|
+
parent_options = @options
|
|
110
|
+
|
|
111
|
+
Schema.new(**parent_options.merge(options)) do
|
|
112
|
+
# Copy parent fields
|
|
113
|
+
parent_fields.each_value do |f|
|
|
114
|
+
@schema.add_field(f)
|
|
115
|
+
end
|
|
116
|
+
# Copy parent validators
|
|
117
|
+
parent_validators.each do |v|
|
|
118
|
+
@schema.add_validator(v)
|
|
119
|
+
end
|
|
120
|
+
# Add new fields/validators from block
|
|
121
|
+
instance_eval(&block) if block
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Create a new schema with only specified fields
|
|
126
|
+
def pick(*field_names, **options)
|
|
127
|
+
field_names = field_names.map(&:to_sym)
|
|
128
|
+
selected_fields = @fields.slice(*field_names)
|
|
129
|
+
parent_options = @options
|
|
130
|
+
|
|
131
|
+
Schema.new(**parent_options.merge(options)) do
|
|
132
|
+
selected_fields.each_value do |f|
|
|
133
|
+
@schema.add_field(f)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Create a new schema without specified fields
|
|
139
|
+
def omit(*field_names, **options)
|
|
140
|
+
field_names = field_names.map(&:to_sym)
|
|
141
|
+
remaining_fields = @fields.reject { |k, _| field_names.include?(k) }
|
|
142
|
+
parent_options = @options
|
|
143
|
+
|
|
144
|
+
Schema.new(**parent_options.merge(options)) do
|
|
145
|
+
remaining_fields.each_value do |f|
|
|
146
|
+
@schema.add_field(f)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Merge another schema into this one (other schema's fields take precedence)
|
|
152
|
+
def merge(other, **options)
|
|
153
|
+
raise ArgumentError, "Expected Schema, got #{other.class}" unless other.is_a?(Schema)
|
|
154
|
+
|
|
155
|
+
parent_fields = @fields
|
|
156
|
+
parent_validators = @validators
|
|
157
|
+
other_fields = other.fields
|
|
158
|
+
other_validators = other.validators
|
|
159
|
+
parent_options = @options
|
|
160
|
+
|
|
161
|
+
Schema.new(**parent_options.merge(options)) do
|
|
162
|
+
parent_fields.each_value do |f|
|
|
163
|
+
@schema.add_field(f) unless other_fields.key?(f.name)
|
|
164
|
+
end
|
|
165
|
+
other_fields.each_value do |f|
|
|
166
|
+
@schema.add_field(f)
|
|
167
|
+
end
|
|
168
|
+
parent_validators.each { |v| @schema.add_validator(v) }
|
|
169
|
+
other_validators.each { |v| @schema.add_validator(v) }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Create a new schema with all fields optional
|
|
174
|
+
def partial(**options)
|
|
175
|
+
parent_fields = @fields
|
|
176
|
+
parent_options = @options
|
|
177
|
+
|
|
178
|
+
Schema.new(**parent_options.merge(options)) do
|
|
179
|
+
parent_fields.each do |name, f|
|
|
180
|
+
# Rebuild field as optional
|
|
181
|
+
field = Field.new(
|
|
182
|
+
name,
|
|
183
|
+
f.type,
|
|
184
|
+
optional: true,
|
|
185
|
+
**f.options.reject { |k, _| k == :optional }
|
|
186
|
+
)
|
|
187
|
+
@schema.add_field(field)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def normalize_options(options)
|
|
195
|
+
{
|
|
196
|
+
strict: options[:strict] || false,
|
|
197
|
+
passthrough: options[:passthrough] || false
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def normalize_context(context)
|
|
202
|
+
case context
|
|
203
|
+
when Context
|
|
204
|
+
context
|
|
205
|
+
when Hash
|
|
206
|
+
Context.new(**context)
|
|
207
|
+
when nil
|
|
208
|
+
Context.empty
|
|
209
|
+
else
|
|
210
|
+
raise ArgumentError, "Expected Context or Hash, got #{context.class}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def normalize_input(data)
|
|
215
|
+
return {} if data.nil?
|
|
216
|
+
|
|
217
|
+
raise ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(Hash)
|
|
218
|
+
|
|
219
|
+
# Convert string keys to symbols
|
|
220
|
+
data.transform_keys(&:to_sym)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def fetch_value(data, name)
|
|
224
|
+
return data[name] if data.key?(name)
|
|
225
|
+
|
|
226
|
+
# Also check string key
|
|
227
|
+
string_key = name.to_s
|
|
228
|
+
return data[string_key] if data.key?(string_key)
|
|
229
|
+
|
|
230
|
+
Field::MISSING
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def run_validators(data, path_prefix, context = nil)
|
|
234
|
+
errors = []
|
|
235
|
+
validator_ctx = ValidatorContext.new(data, path_prefix, context)
|
|
236
|
+
|
|
237
|
+
@validators.each do |validator|
|
|
238
|
+
if validator.arity <= 1
|
|
239
|
+
validator_ctx.instance_exec(data, &validator)
|
|
240
|
+
else
|
|
241
|
+
validator_ctx.instance_exec(data, context, &validator)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
validator_ctx.errors
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Context object for custom validators
|
|
249
|
+
class ValidatorContext
|
|
250
|
+
attr_reader :errors, :context
|
|
251
|
+
|
|
252
|
+
def initialize(data, path_prefix, context = nil)
|
|
253
|
+
@data = data
|
|
254
|
+
@path_prefix = path_prefix
|
|
255
|
+
@context = context
|
|
256
|
+
@errors = []
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Add an error for a specific field
|
|
260
|
+
def error(field, message, code: :custom)
|
|
261
|
+
path = @path_prefix + [field.to_sym]
|
|
262
|
+
@errors << Error.new(path: path, message: message, code: code)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Add a base-level error (not tied to a specific field)
|
|
266
|
+
def base_error(message, code: :custom)
|
|
267
|
+
@errors << Error.new(path: @path_prefix, message: message, code: code)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Access field values
|
|
271
|
+
def [](field)
|
|
272
|
+
@data[field.to_sym]
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# DSL Builder for defining fields
|
|
277
|
+
class Builder
|
|
278
|
+
def initialize(schema)
|
|
279
|
+
@schema = schema
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def field(name, type, **options)
|
|
283
|
+
field = Field.new(name, type, **options)
|
|
284
|
+
@schema.add_field(field)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Shorthand for optional field
|
|
288
|
+
def optional(name, type, **options)
|
|
289
|
+
field(name, type, **options.merge(optional: true))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Shorthand for required field (explicit)
|
|
293
|
+
def required(name, type, **options)
|
|
294
|
+
field(name, type, **options.merge(optional: false))
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Add a custom validator block
|
|
298
|
+
def validate(&block)
|
|
299
|
+
@schema.add_validator(block)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Validrb
|
|
6
|
+
# Serializer for converting validated data back to primitives
|
|
7
|
+
# Useful for JSON serialization, database storage, etc.
|
|
8
|
+
class Serializer
|
|
9
|
+
# Serialize a value to a primitive representation
|
|
10
|
+
def self.dump(value, format: :hash)
|
|
11
|
+
serialized = serialize_value(value)
|
|
12
|
+
|
|
13
|
+
case format
|
|
14
|
+
when :hash
|
|
15
|
+
serialized
|
|
16
|
+
when :json
|
|
17
|
+
JSON.generate(serialized)
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, "Unknown format: #{format}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Serialize a value recursively
|
|
24
|
+
def self.serialize_value(value)
|
|
25
|
+
case value
|
|
26
|
+
when nil, true, false, ::Integer, ::Float, ::String
|
|
27
|
+
value
|
|
28
|
+
when ::Symbol
|
|
29
|
+
value.to_s
|
|
30
|
+
when ::BigDecimal
|
|
31
|
+
value.to_s("F")
|
|
32
|
+
when ::Date
|
|
33
|
+
value.iso8601
|
|
34
|
+
when ::DateTime
|
|
35
|
+
value.iso8601
|
|
36
|
+
when ::Time
|
|
37
|
+
value.iso8601
|
|
38
|
+
when ::Array
|
|
39
|
+
value.map { |v| serialize_value(v) }
|
|
40
|
+
when ::Hash
|
|
41
|
+
value.transform_keys(&:to_s).transform_values { |v| serialize_value(v) }
|
|
42
|
+
else
|
|
43
|
+
# Try to_h, to_s as fallbacks
|
|
44
|
+
if value.respond_to?(:to_h)
|
|
45
|
+
serialize_value(value.to_h)
|
|
46
|
+
else
|
|
47
|
+
value.to_s
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Add serialization to Result
|
|
54
|
+
class Success
|
|
55
|
+
def dump(format: :hash)
|
|
56
|
+
Serializer.dump(@data, format: format)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_json(*args)
|
|
60
|
+
dump(format: :json)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class Failure
|
|
65
|
+
def dump(format: :hash)
|
|
66
|
+
serialized_errors = @errors.map do |e|
|
|
67
|
+
{
|
|
68
|
+
"path" => e.path.map(&:to_s),
|
|
69
|
+
"message" => e.message,
|
|
70
|
+
"code" => e.code.to_s
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
case format
|
|
75
|
+
when :hash
|
|
76
|
+
{ "errors" => serialized_errors }
|
|
77
|
+
when :json
|
|
78
|
+
JSON.generate({ "errors" => serialized_errors })
|
|
79
|
+
else
|
|
80
|
+
raise ArgumentError, "Unknown format: #{format}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_json(*args)
|
|
85
|
+
dump(format: :json)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Add serialization to Schema
|
|
90
|
+
class Schema
|
|
91
|
+
# Serialize validated data
|
|
92
|
+
def dump(data, format: :hash)
|
|
93
|
+
result = safe_parse(data)
|
|
94
|
+
|
|
95
|
+
if result.success?
|
|
96
|
+
result.dump(format: format)
|
|
97
|
+
else
|
|
98
|
+
raise ValidationError, result.errors
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Parse and serialize in one step (returns Result with serialized data)
|
|
103
|
+
def safe_dump(data, format: :hash)
|
|
104
|
+
result = safe_parse(data)
|
|
105
|
+
|
|
106
|
+
if result.success?
|
|
107
|
+
Success.new(Serializer.dump(result.data, format: :hash))
|
|
108
|
+
else
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# Array type with optional item type validation
|
|
6
|
+
class Array < Base
|
|
7
|
+
attr_reader :item_type
|
|
8
|
+
|
|
9
|
+
def initialize(of: nil, **options)
|
|
10
|
+
@item_type = of
|
|
11
|
+
super(**options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def coerce(value)
|
|
15
|
+
return COERCION_FAILED unless value.is_a?(::Array)
|
|
16
|
+
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def valid?(value)
|
|
21
|
+
value.is_a?(::Array)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Override call to handle item validation
|
|
25
|
+
def call(value, path: [])
|
|
26
|
+
coerced = coerce(value)
|
|
27
|
+
|
|
28
|
+
if coerced.equal?(COERCION_FAILED)
|
|
29
|
+
return [nil, [Error.new(path: path, message: coercion_error_message(value), code: :type_error)]]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
return [coerced, []] unless @item_type
|
|
33
|
+
|
|
34
|
+
validate_items(coerced, path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def type_name
|
|
38
|
+
@item_type ? "array<#{item_type_name}>" : "array"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_items(array, path)
|
|
44
|
+
type_instance = resolve_item_type
|
|
45
|
+
result_array = []
|
|
46
|
+
errors = []
|
|
47
|
+
|
|
48
|
+
array.each_with_index do |item, index|
|
|
49
|
+
item_path = path + [index]
|
|
50
|
+
coerced_item, item_errors = type_instance.call(item, path: item_path)
|
|
51
|
+
|
|
52
|
+
if item_errors.empty?
|
|
53
|
+
result_array << coerced_item
|
|
54
|
+
else
|
|
55
|
+
errors.concat(item_errors)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
errors.empty? ? [result_array, []] : [nil, errors]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resolve_item_type
|
|
63
|
+
case @item_type
|
|
64
|
+
when Symbol
|
|
65
|
+
Types.build(@item_type)
|
|
66
|
+
when Types::Base
|
|
67
|
+
@item_type
|
|
68
|
+
when Class
|
|
69
|
+
@item_type.new
|
|
70
|
+
else
|
|
71
|
+
raise ArgumentError, "Invalid item type: #{@item_type.inspect}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def item_type_name
|
|
76
|
+
case @item_type
|
|
77
|
+
when Symbol
|
|
78
|
+
@item_type.to_s
|
|
79
|
+
when Types::Base
|
|
80
|
+
@item_type.type_name
|
|
81
|
+
when Class
|
|
82
|
+
@item_type.name.split("::").last.downcase
|
|
83
|
+
else
|
|
84
|
+
"unknown"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
register(:array, Array)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# Sentinel object for failed coercion (distinguishes from nil)
|
|
6
|
+
COERCION_FAILED = Object.new.tap do |obj|
|
|
7
|
+
def obj.inspect
|
|
8
|
+
"COERCION_FAILED"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def obj.to_s
|
|
12
|
+
"COERCION_FAILED"
|
|
13
|
+
end
|
|
14
|
+
end.freeze
|
|
15
|
+
|
|
16
|
+
# Registry for types
|
|
17
|
+
@registry = {}
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
attr_reader :registry
|
|
21
|
+
|
|
22
|
+
def register(name, klass)
|
|
23
|
+
@registry[name.to_sym] = klass
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def lookup(name)
|
|
27
|
+
@registry[name.to_sym]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build(name, **options)
|
|
31
|
+
klass = lookup(name)
|
|
32
|
+
raise ArgumentError, "Unknown type: #{name}" unless klass
|
|
33
|
+
|
|
34
|
+
klass.new(**options)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Base class for all types
|
|
39
|
+
class Base
|
|
40
|
+
attr_reader :options
|
|
41
|
+
|
|
42
|
+
def initialize(**options)
|
|
43
|
+
@options = options.freeze
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Main entry point: coerce and validate a value
|
|
48
|
+
# Returns [coerced_value, errors_array]
|
|
49
|
+
def call(value, path: [])
|
|
50
|
+
coerced = coerce(value)
|
|
51
|
+
|
|
52
|
+
if coerced.equal?(COERCION_FAILED)
|
|
53
|
+
return [nil, [Error.new(path: path, message: coercion_error_message(value), code: :type_error)]]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless valid?(coerced)
|
|
57
|
+
return [nil, [Error.new(path: path, message: validation_error_message(coerced), code: :type_error)]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
[coerced, []]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Override in subclasses: attempt to coerce value to target type
|
|
64
|
+
# Return COERCION_FAILED if coercion is not possible
|
|
65
|
+
def coerce(value)
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Override in subclasses: validate that value is correct type
|
|
70
|
+
def valid?(_value)
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Override in subclasses: type name for error messages
|
|
75
|
+
def type_name
|
|
76
|
+
self.class.name.split("::").last.downcase
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Override in subclasses: error message for failed coercion
|
|
80
|
+
def coercion_error_message(value)
|
|
81
|
+
"cannot coerce #{value.class} to #{type_name}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Override in subclasses: error message for failed validation
|
|
85
|
+
def validation_error_message(_value)
|
|
86
|
+
"must be a #{type_name}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# Boolean type with coercion from String/Integer
|
|
6
|
+
class Boolean < Base
|
|
7
|
+
TRUTHY_VALUES = [true, 1, "1", "true", "yes", "on", "t", "y"].freeze
|
|
8
|
+
FALSY_VALUES = [false, 0, "0", "false", "no", "off", "f", "n"].freeze
|
|
9
|
+
|
|
10
|
+
def coerce(value)
|
|
11
|
+
return true if TRUTHY_VALUES.include?(normalize(value))
|
|
12
|
+
return false if FALSY_VALUES.include?(normalize(value))
|
|
13
|
+
|
|
14
|
+
COERCION_FAILED
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def valid?(value)
|
|
18
|
+
value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def type_name
|
|
22
|
+
"boolean"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def normalize(value)
|
|
28
|
+
return value.downcase if value.is_a?(::String)
|
|
29
|
+
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
register(:boolean, Boolean)
|
|
35
|
+
register(:bool, Boolean)
|
|
36
|
+
end
|
|
37
|
+
end
|