rbdantic 0.1.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/.rspec +3 -0
- data/.rubocop.yml +245 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/README_CN.md +852 -0
- data/Rakefile +12 -0
- data/lib/rbdantic/base/access.rb +105 -0
- data/lib/rbdantic/base/dsl.rb +79 -0
- data/lib/rbdantic/base/validation.rb +152 -0
- data/lib/rbdantic/base.rb +30 -0
- data/lib/rbdantic/config.rb +60 -0
- data/lib/rbdantic/error_detail.rb +54 -0
- data/lib/rbdantic/field.rb +188 -0
- data/lib/rbdantic/json_schema/defs_registry.rb +79 -0
- data/lib/rbdantic/json_schema/generator.rb +148 -0
- data/lib/rbdantic/json_schema/types.rb +98 -0
- data/lib/rbdantic/serialization/dumper.rb +133 -0
- data/lib/rbdantic/serialization/json_serializer.rb +60 -0
- data/lib/rbdantic/validators/field_validator.rb +83 -0
- data/lib/rbdantic/validators/model_validator.rb +59 -0
- data/lib/rbdantic/validators/types/array.rb +77 -0
- data/lib/rbdantic/validators/types/base.rb +78 -0
- data/lib/rbdantic/validators/types/boolean.rb +37 -0
- data/lib/rbdantic/validators/types/float.rb +32 -0
- data/lib/rbdantic/validators/types/hash.rb +54 -0
- data/lib/rbdantic/validators/types/integer.rb +28 -0
- data/lib/rbdantic/validators/types/model.rb +75 -0
- data/lib/rbdantic/validators/types/number.rb +63 -0
- data/lib/rbdantic/validators/types/string.rb +70 -0
- data/lib/rbdantic/validators/types/symbol.rb +30 -0
- data/lib/rbdantic/validators/types/time.rb +33 -0
- data/lib/rbdantic/validators/types.rb +63 -0
- data/lib/rbdantic/validators/validator_context.rb +43 -0
- data/lib/rbdantic/version.rb +5 -0
- data/lib/rbdantic.rb +8 -0
- data/sig/rbdantic.rbs +4 -0
- metadata +84 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Rbdantic
|
|
7
|
+
module Validators
|
|
8
|
+
module Types
|
|
9
|
+
class Array < Base
|
|
10
|
+
attr_reader :element_type, :item_validator
|
|
11
|
+
|
|
12
|
+
def initialize(element_type: nil, **constraints)
|
|
13
|
+
super(**constraints)
|
|
14
|
+
@element_type = element_type
|
|
15
|
+
@item_validator = Types.create_validator(element_type) if element_type
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def matches_type?(value)
|
|
19
|
+
value.is_a?(::Array)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def expected_type_name
|
|
23
|
+
"Array"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_constraints(value, errors, location, strict: false)
|
|
27
|
+
validate_length(value, errors, location)
|
|
28
|
+
validate_unique(value, errors, location)
|
|
29
|
+
validate_items(value, errors, location, strict: strict)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def coerce(value)
|
|
33
|
+
case value
|
|
34
|
+
when ::String
|
|
35
|
+
begin
|
|
36
|
+
parsed = JSON.parse(value)
|
|
37
|
+
parsed if parsed.is_a?(::Array)
|
|
38
|
+
rescue JSON::ParserError; nil
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
value.respond_to?(:to_a) ? value.to_a : nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def validate_length(value, errors, location)
|
|
48
|
+
if constraints[:min_items] && value.length < constraints[:min_items]
|
|
49
|
+
errors << error(type: :array_too_short, loc: location,
|
|
50
|
+
msg: "Array must have at least #{constraints[:min_items]} items", input: value)
|
|
51
|
+
end
|
|
52
|
+
if constraints[:max_items] && value.length > constraints[:max_items]
|
|
53
|
+
errors << error(type: :array_too_long, loc: location,
|
|
54
|
+
msg: "Array must have at most #{constraints[:max_items]} items", input: value)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_unique(value, errors, location)
|
|
59
|
+
if constraints[:unique_items] && value.uniq.length != value.length
|
|
60
|
+
errors << error(type: :array_items_not_unique, loc: location, msg: "Array items must be unique",
|
|
61
|
+
input: value)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_items(value, errors, location, strict: false)
|
|
66
|
+
return value unless @item_validator
|
|
67
|
+
|
|
68
|
+
value.map.with_index do |item, index|
|
|
69
|
+
item_errors, coerced_item = @item_validator.validate(item, location + [index], strict: strict)
|
|
70
|
+
errors.concat(item_errors)
|
|
71
|
+
coerced_item
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbdantic
|
|
4
|
+
module Validators
|
|
5
|
+
module Types
|
|
6
|
+
# Base class for type validators
|
|
7
|
+
# Provides common validation pattern with type checking and coercion
|
|
8
|
+
class Base
|
|
9
|
+
attr_reader :constraints
|
|
10
|
+
|
|
11
|
+
def initialize(**constraints)
|
|
12
|
+
@constraints = constraints
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Main validation entry point
|
|
16
|
+
# @return [Array<ErrorDetail>, value] tuple of errors and validated/coerced value
|
|
17
|
+
def validate(value, location = [], strict: false)
|
|
18
|
+
errors = []
|
|
19
|
+
|
|
20
|
+
# Type check with optional coercion
|
|
21
|
+
unless matches_type?(value)
|
|
22
|
+
if !strict && !(coerced = coerce(value)).nil?
|
|
23
|
+
value = coerced
|
|
24
|
+
else
|
|
25
|
+
errors << type_error(value, location)
|
|
26
|
+
return [errors, value]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Constraint validation (override in subclasses)
|
|
31
|
+
# Returns potentially transformed value (e.g., for nested type coercion)
|
|
32
|
+
value = validate_constraints(value, errors, location, strict: strict)
|
|
33
|
+
|
|
34
|
+
[errors, value]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Override in subclasses to define type matching
|
|
38
|
+
# @return [Boolean] true if value matches expected type
|
|
39
|
+
def matches_type?(value)
|
|
40
|
+
raise NotImplementedError, "Type validators must implement #matches_type?"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Override in subclasses to define coercion logic
|
|
44
|
+
# @return [Object, nil] coerced value or nil if cannot coerce
|
|
45
|
+
def coerce(_value)
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Override in subclasses to add constraint validation
|
|
50
|
+
# @param value the validated value
|
|
51
|
+
# @param errors [Array] error accumulator
|
|
52
|
+
# @param location [Array] current location path
|
|
53
|
+
# @param strict [Boolean] strict mode flag
|
|
54
|
+
# @return [Object] the (potentially transformed) value
|
|
55
|
+
def validate_constraints(value, _errors, _location, strict: false)
|
|
56
|
+
value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Expected type name for error messages
|
|
60
|
+
# @return [String] human-readable type name
|
|
61
|
+
def expected_type_name
|
|
62
|
+
raise NotImplementedError, "Type validators must implement #expected_type_name"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
protected
|
|
66
|
+
|
|
67
|
+
def error(type:, loc:, msg:, input: nil)
|
|
68
|
+
ErrorDetail.new(type: type, loc: loc, msg: msg, input: input)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def type_error(value, location)
|
|
72
|
+
error(type: :type_error, loc: location, msg: "Expected #{expected_type_name}, got #{value.class.name}",
|
|
73
|
+
input: value)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Rbdantic
|
|
6
|
+
module Validators
|
|
7
|
+
module Types
|
|
8
|
+
class Boolean < Base
|
|
9
|
+
def matches_type?(value)
|
|
10
|
+
value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def expected_type_name
|
|
14
|
+
"Boolean"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def coerce(value)
|
|
18
|
+
return value if value == true || value == false
|
|
19
|
+
|
|
20
|
+
case value
|
|
21
|
+
when ::String
|
|
22
|
+
str = value.strip.downcase
|
|
23
|
+
return true if str == "true" || str == "1" || str == "yes" || str == "on"
|
|
24
|
+
return false if str == "false" || str == "0" || str == "no" || str == "off"
|
|
25
|
+
nil
|
|
26
|
+
when ::Integer
|
|
27
|
+
return true if value == 1
|
|
28
|
+
return false if value == 0
|
|
29
|
+
nil
|
|
30
|
+
else
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "number"
|
|
4
|
+
|
|
5
|
+
module Rbdantic
|
|
6
|
+
module Validators
|
|
7
|
+
module Types
|
|
8
|
+
class Float < Number
|
|
9
|
+
def matches_type?(value)
|
|
10
|
+
value.is_a?(::Float)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def expected_type_name
|
|
14
|
+
"Float"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def numeric_type?
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def coerce(value)
|
|
22
|
+
case value
|
|
23
|
+
when ::Float then value
|
|
24
|
+
when ::Integer then value.to_f
|
|
25
|
+
when ::String then Float(value) rescue nil
|
|
26
|
+
else nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Rbdantic
|
|
7
|
+
module Validators
|
|
8
|
+
module Types
|
|
9
|
+
class Hash < Base
|
|
10
|
+
def matches_type?(value)
|
|
11
|
+
value.is_a?(::Hash)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def expected_type_name
|
|
15
|
+
"Hash"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def validate_constraints(value, errors, location, strict: false)
|
|
19
|
+
validate_size(value, errors, location)
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def coerce(value)
|
|
24
|
+
case value
|
|
25
|
+
when ::Hash then value
|
|
26
|
+
when ::Array
|
|
27
|
+
value.to_h if value.all? { |item| item.is_a?(::Array) && item.length == 2 }
|
|
28
|
+
when ::String
|
|
29
|
+
begin
|
|
30
|
+
parsed = JSON.parse(value)
|
|
31
|
+
parsed if parsed.is_a?(::Hash)
|
|
32
|
+
rescue JSON::ParserError; nil
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
value.to_h if value.respond_to?(:to_h)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def validate_size(value, errors, location)
|
|
42
|
+
if constraints[:min_properties] && value.length < constraints[:min_properties]
|
|
43
|
+
errors << error(type: :hash_too_few_properties, loc: location,
|
|
44
|
+
msg: "Hash must have at least #{constraints[:min_properties]} properties", input: value)
|
|
45
|
+
end
|
|
46
|
+
if constraints[:max_properties] && value.length > constraints[:max_properties]
|
|
47
|
+
errors << error(type: :hash_too_many_properties, loc: location,
|
|
48
|
+
msg: "Hash must have at most #{constraints[:max_properties]} properties", input: value)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "number"
|
|
4
|
+
|
|
5
|
+
module Rbdantic
|
|
6
|
+
module Validators
|
|
7
|
+
module Types
|
|
8
|
+
class Integer < Number
|
|
9
|
+
def matches_type?(value)
|
|
10
|
+
value.is_a?(::Integer)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def expected_type_name
|
|
14
|
+
"Integer"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def coerce(value)
|
|
18
|
+
case value
|
|
19
|
+
when ::Integer then value
|
|
20
|
+
when ::Float then value.to_i if value == value.floor
|
|
21
|
+
when ::String then Integer(value) rescue nil
|
|
22
|
+
else nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Rbdantic
|
|
6
|
+
module Validators
|
|
7
|
+
module Types
|
|
8
|
+
# Validator for nested model types
|
|
9
|
+
class Model < Base
|
|
10
|
+
attr_reader :model_class
|
|
11
|
+
|
|
12
|
+
def initialize(model_class, **constraints)
|
|
13
|
+
super(**constraints)
|
|
14
|
+
@model_class = model_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def matches_type?(value)
|
|
18
|
+
value.is_a?(@model_class)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def expected_type_name
|
|
22
|
+
@model_class.name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Model validation has special handling for nil and Hash
|
|
26
|
+
# so we override validate entirely
|
|
27
|
+
def validate(value, location = [], strict: false)
|
|
28
|
+
return [[], nil] if value.nil?
|
|
29
|
+
|
|
30
|
+
if matches_type?(value)
|
|
31
|
+
return [validate_existing_instance(value, location, strict: strict), value]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if value.is_a?(::Hash)
|
|
35
|
+
if strict
|
|
36
|
+
return [
|
|
37
|
+
[error(type: :type_error, loc: location, msg: "Expected #{expected_type_name}, got Hash", input: value)],
|
|
38
|
+
value
|
|
39
|
+
]
|
|
40
|
+
end
|
|
41
|
+
begin
|
|
42
|
+
return [[], @model_class.new(value)]
|
|
43
|
+
rescue ValidationError => e
|
|
44
|
+
return [remap_errors(e.errors, location), value]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
[
|
|
49
|
+
[error(type: :type_error, loc: location, msg: "Expected #{expected_type_name}, got #{value.class.name}", input: value)],
|
|
50
|
+
value
|
|
51
|
+
]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_constraints(value, _errors, _location, strict: false)
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def validate_existing_instance(model, location, strict: false)
|
|
61
|
+
model.class.new(model.model_dump)
|
|
62
|
+
[]
|
|
63
|
+
rescue ValidationError => e
|
|
64
|
+
remap_errors(e.errors, location)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def remap_errors(errors, location)
|
|
68
|
+
errors.map do |err|
|
|
69
|
+
ErrorDetail.new(type: err.type, loc: location + err.loc, msg: err.msg, input: err.input)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbdantic
|
|
4
|
+
module Validators
|
|
5
|
+
module Types
|
|
6
|
+
# Abstract base for numeric validators (Integer, Float)
|
|
7
|
+
class Number < Base
|
|
8
|
+
# Tolerance constants for floating-point comparison
|
|
9
|
+
FLOAT_TOLERANCE_FACTOR = 1e-9
|
|
10
|
+
MIN_FLOAT_TOLERANCE = 1e-12
|
|
11
|
+
|
|
12
|
+
def matches_type?
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def expected_type_name
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate_constraints(value, errors, location, strict: false)
|
|
21
|
+
validate_bounds(value, errors, location)
|
|
22
|
+
validate_multiple_of(value, errors, location) if constraints[:multiple_of]
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def validate_bounds(value, errors, location)
|
|
29
|
+
if constraints[:gt] && value <= constraints[:gt]
|
|
30
|
+
errors << error(type: :value_not_greater_than, loc: location,
|
|
31
|
+
msg: "Value must be greater than #{constraints[:gt]}", input: value)
|
|
32
|
+
end
|
|
33
|
+
if constraints[:ge] && value < constraints[:ge]
|
|
34
|
+
errors << error(type: :value_not_greater_than_or_equal, loc: location,
|
|
35
|
+
msg: "Value must be greater than or equal to #{constraints[:ge]}", input: value)
|
|
36
|
+
end
|
|
37
|
+
if constraints[:lt] && value >= constraints[:lt]
|
|
38
|
+
errors << error(type: :value_not_less_than, loc: location,
|
|
39
|
+
msg: "Value must be less than #{constraints[:lt]}", input: value)
|
|
40
|
+
end
|
|
41
|
+
if constraints[:le] && value > constraints[:le]
|
|
42
|
+
errors << error(type: :value_not_less_than_or_equal, loc: location,
|
|
43
|
+
msg: "Value must be less than or equal to #{constraints[:le]}", input: value)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_multiple_of(value, errors, location)
|
|
48
|
+
multiple = constraints[:multiple_of].abs
|
|
49
|
+
remainder = value % multiple
|
|
50
|
+
tolerance = numeric_type? ? [multiple * FLOAT_TOLERANCE_FACTOR, MIN_FLOAT_TOLERANCE].max : 0
|
|
51
|
+
if remainder > tolerance && remainder < multiple - tolerance
|
|
52
|
+
errors << error(type: :value_not_multiple_of, loc: location,
|
|
53
|
+
msg: "Value must be a multiple of #{constraints[:multiple_of]}", input: value)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def numeric_type?
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Rbdantic
|
|
6
|
+
module Validators
|
|
7
|
+
module Types
|
|
8
|
+
class String < Base
|
|
9
|
+
FORMAT_PATTERNS = {
|
|
10
|
+
email: /\A[^@\s]+@[^@\s]+\z/,
|
|
11
|
+
uri: /\A[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]+\z/,
|
|
12
|
+
uuid: /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def matches_type?(value)
|
|
16
|
+
value.is_a?(::String)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def expected_type_name
|
|
20
|
+
"String"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def coerce(value)
|
|
24
|
+
case value
|
|
25
|
+
when ::String then value
|
|
26
|
+
when ::Symbol, Numeric, TrueClass, FalseClass then value.to_s
|
|
27
|
+
else nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_constraints(value, errors, location, strict: false)
|
|
32
|
+
validate_length(value, errors, location)
|
|
33
|
+
validate_pattern(value, errors, location)
|
|
34
|
+
validate_format(value, errors, location)
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_length(value, errors, location)
|
|
41
|
+
if constraints[:min_length] && value.length < constraints[:min_length]
|
|
42
|
+
errors << error(type: :string_too_short, loc: location,
|
|
43
|
+
msg: "String must be at least #{constraints[:min_length]} characters", input: value)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if constraints[:max_length] && value.length > constraints[:max_length]
|
|
47
|
+
errors << error(type: :string_too_long, loc: location,
|
|
48
|
+
msg: "String must be at most #{constraints[:max_length]} characters", input: value)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_pattern(value, errors, location)
|
|
53
|
+
if constraints[:pattern] && !constraints[:pattern].match?(value)
|
|
54
|
+
errors << error(type: :string_pattern_mismatch, loc: location,
|
|
55
|
+
msg: "String does not match pattern #{constraints[:pattern].source}", input: value)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_format(value, errors, location)
|
|
60
|
+
if constraints[:format] && FORMAT_PATTERNS.key?(constraints[:format])
|
|
61
|
+
unless FORMAT_PATTERNS[constraints[:format]].match?(value)
|
|
62
|
+
errors << error(type: :string_format_mismatch, loc: location,
|
|
63
|
+
msg: "String does not match format '#{constraints[:format]}'", input: value)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Rbdantic
|
|
6
|
+
module Validators
|
|
7
|
+
module Types
|
|
8
|
+
class Symbol < Base
|
|
9
|
+
MAX_SYMBOL_LENGTH = 256
|
|
10
|
+
|
|
11
|
+
def matches_type?(value)
|
|
12
|
+
value.is_a?(::Symbol)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def expected_type_name
|
|
16
|
+
"Symbol"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def coerce(value)
|
|
20
|
+
case value
|
|
21
|
+
when ::Symbol then value
|
|
22
|
+
when ::String
|
|
23
|
+
value.to_sym if !value.empty? && value.length <= MAX_SYMBOL_LENGTH
|
|
24
|
+
else nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Rbdantic
|
|
7
|
+
module Validators
|
|
8
|
+
module Types
|
|
9
|
+
class Time < Base
|
|
10
|
+
def matches_type?(value)
|
|
11
|
+
value.is_a?(::Time)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def expected_type_name
|
|
15
|
+
"Time"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def coerce(value)
|
|
19
|
+
case value
|
|
20
|
+
when ::Time
|
|
21
|
+
value
|
|
22
|
+
when ::String
|
|
23
|
+
::Time.iso8601(value)
|
|
24
|
+
when ::Integer, ::Float
|
|
25
|
+
::Time.at(value)
|
|
26
|
+
end
|
|
27
|
+
rescue ArgumentError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "types/base"
|
|
4
|
+
require_relative "types/number"
|
|
5
|
+
require_relative "types/string"
|
|
6
|
+
require_relative "types/integer"
|
|
7
|
+
require_relative "types/float"
|
|
8
|
+
require_relative "types/boolean"
|
|
9
|
+
require_relative "types/array"
|
|
10
|
+
require_relative "types/hash"
|
|
11
|
+
require_relative "types/symbol"
|
|
12
|
+
require_relative "types/time"
|
|
13
|
+
require_relative "types/model"
|
|
14
|
+
|
|
15
|
+
module Rbdantic
|
|
16
|
+
# Marker class for boolean type validation (since Ruby has TrueClass and FalseClass)
|
|
17
|
+
class Boolean; end
|
|
18
|
+
|
|
19
|
+
module Validators
|
|
20
|
+
module Types
|
|
21
|
+
VALIDATOR_CLASSES = {
|
|
22
|
+
::String => Validators::Types::String,
|
|
23
|
+
::Integer => Validators::Types::Integer,
|
|
24
|
+
::Float => Validators::Types::Float,
|
|
25
|
+
Rbdantic::Boolean => Validators::Types::Boolean,
|
|
26
|
+
::Array => Validators::Types::Array,
|
|
27
|
+
::Hash => Validators::Types::Hash,
|
|
28
|
+
::Symbol => Validators::Types::Symbol,
|
|
29
|
+
::Time => Validators::Types::Time
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def self.validator_class_for(type)
|
|
33
|
+
VALIDATOR_CLASSES[type]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Create validator for type with constraints
|
|
37
|
+
# @param type [Class] the field type
|
|
38
|
+
# @param constraints [Hash] constraint options (min_length, gt, etc.)
|
|
39
|
+
# @return [Base, nil] validator instance
|
|
40
|
+
def self.create_validator(type, **constraints)
|
|
41
|
+
return nil if type.nil?
|
|
42
|
+
|
|
43
|
+
if nested_model?(type)
|
|
44
|
+
return Validators::Types::Model.new(type, **constraints)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
validator_class = VALIDATOR_CLASSES[type]
|
|
48
|
+
raise ArgumentError, "Unsupported field type: #{type}" unless validator_class
|
|
49
|
+
|
|
50
|
+
validator_class.new(**constraints)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.has_validator?(type)
|
|
54
|
+
VALIDATOR_CLASSES.key?(type)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if type is a nested model
|
|
58
|
+
def self.nested_model?(type)
|
|
59
|
+
type.is_a?(Class) && type < Rbdantic::BaseModel && !type.equal?(Rbdantic::BaseModel)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbdantic
|
|
4
|
+
module Validators
|
|
5
|
+
# Context object passed to validators during validation
|
|
6
|
+
class ValidatorContext
|
|
7
|
+
attr_reader :field_name, :field_info, :model_class, :model_instance, :data
|
|
8
|
+
|
|
9
|
+
def initialize(
|
|
10
|
+
field_name: nil,
|
|
11
|
+
field_info: nil,
|
|
12
|
+
model_class: nil,
|
|
13
|
+
model_instance: nil,
|
|
14
|
+
data: {}
|
|
15
|
+
)
|
|
16
|
+
@field_name = field_name
|
|
17
|
+
@field_info = field_info
|
|
18
|
+
@model_class = model_class
|
|
19
|
+
@model_instance = model_instance
|
|
20
|
+
@data = data
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Create an error detail object
|
|
24
|
+
def create_error(type:, msg:, input: nil)
|
|
25
|
+
ErrorDetail.new(
|
|
26
|
+
type: type,
|
|
27
|
+
loc: [@field_name],
|
|
28
|
+
msg: msg,
|
|
29
|
+
input: input
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Access other field values (for cross-field validation)
|
|
34
|
+
def field_value(name)
|
|
35
|
+
if @data.key?(name)
|
|
36
|
+
@data[name]
|
|
37
|
+
else
|
|
38
|
+
@model_instance&.instance_variable_get("@#{name}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|