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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Validrb
|
|
6
|
+
module Types
|
|
7
|
+
# Date type with coercion from String (ISO8601) and Time/DateTime
|
|
8
|
+
class Date < Base
|
|
9
|
+
# Common date formats to try (ISO8601-like formats only to avoid ambiguity)
|
|
10
|
+
DATE_FORMATS = [
|
|
11
|
+
"%Y-%m-%d", # 2024-01-15 (ISO8601)
|
|
12
|
+
"%Y/%m/%d" # 2024/01/15
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def coerce(value)
|
|
16
|
+
case value
|
|
17
|
+
when ::DateTime
|
|
18
|
+
# DateTime is a subclass of Date, so check it first
|
|
19
|
+
::Date.new(value.year, value.month, value.day)
|
|
20
|
+
when ::Date
|
|
21
|
+
value
|
|
22
|
+
when ::Time
|
|
23
|
+
value.to_date
|
|
24
|
+
when ::String
|
|
25
|
+
coerce_string(value)
|
|
26
|
+
when ::Integer
|
|
27
|
+
# Unix timestamp
|
|
28
|
+
::Time.at(value).to_date
|
|
29
|
+
else
|
|
30
|
+
COERCION_FAILED
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def valid?(value)
|
|
35
|
+
value.is_a?(::Date) && !value.is_a?(::DateTime)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def type_name
|
|
39
|
+
"date"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def coerce_string(value)
|
|
45
|
+
stripped = value.strip
|
|
46
|
+
return COERCION_FAILED if stripped.empty?
|
|
47
|
+
|
|
48
|
+
# Try ISO8601 first (most common)
|
|
49
|
+
begin
|
|
50
|
+
return ::Date.iso8601(stripped)
|
|
51
|
+
rescue ArgumentError
|
|
52
|
+
# Continue to other formats
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Try other common formats
|
|
56
|
+
DATE_FORMATS.each do |format|
|
|
57
|
+
begin
|
|
58
|
+
return ::Date.strptime(stripped, format)
|
|
59
|
+
rescue ArgumentError
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
COERCION_FAILED
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
register(:date, Date)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Validrb
|
|
7
|
+
module Types
|
|
8
|
+
# DateTime type with coercion from String (ISO8601) and Time/Date
|
|
9
|
+
class DateTime < Base
|
|
10
|
+
def coerce(value)
|
|
11
|
+
case value
|
|
12
|
+
when ::DateTime
|
|
13
|
+
value
|
|
14
|
+
when ::Time
|
|
15
|
+
value.to_datetime
|
|
16
|
+
when ::Date
|
|
17
|
+
value.to_datetime
|
|
18
|
+
when ::String
|
|
19
|
+
coerce_string(value)
|
|
20
|
+
when ::Integer
|
|
21
|
+
# Unix timestamp
|
|
22
|
+
::Time.at(value).to_datetime
|
|
23
|
+
when ::Float
|
|
24
|
+
# Unix timestamp with fractional seconds
|
|
25
|
+
::Time.at(value).to_datetime
|
|
26
|
+
else
|
|
27
|
+
COERCION_FAILED
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def valid?(value)
|
|
32
|
+
value.is_a?(::DateTime)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def type_name
|
|
36
|
+
"datetime"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def coerce_string(value)
|
|
42
|
+
stripped = value.strip
|
|
43
|
+
return COERCION_FAILED if stripped.empty?
|
|
44
|
+
|
|
45
|
+
# Try ISO8601 first
|
|
46
|
+
begin
|
|
47
|
+
return ::DateTime.iso8601(stripped)
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
# Continue
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Try RFC2822 (email date format)
|
|
53
|
+
begin
|
|
54
|
+
return ::DateTime.rfc2822(stripped)
|
|
55
|
+
rescue ArgumentError
|
|
56
|
+
# Continue
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Try Ruby's flexible parsing
|
|
60
|
+
begin
|
|
61
|
+
return ::DateTime.parse(stripped)
|
|
62
|
+
rescue ArgumentError
|
|
63
|
+
COERCION_FAILED
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
register(:datetime, DateTime)
|
|
69
|
+
register(:date_time, DateTime)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Validrb
|
|
6
|
+
module Types
|
|
7
|
+
# Decimal type using BigDecimal for precise monetary values
|
|
8
|
+
class Decimal < Base
|
|
9
|
+
def coerce(value)
|
|
10
|
+
case value
|
|
11
|
+
when ::BigDecimal
|
|
12
|
+
return COERCION_FAILED unless value.finite?
|
|
13
|
+
|
|
14
|
+
value
|
|
15
|
+
when ::Integer
|
|
16
|
+
BigDecimal(value)
|
|
17
|
+
when ::Float
|
|
18
|
+
return COERCION_FAILED unless value.finite?
|
|
19
|
+
|
|
20
|
+
BigDecimal(value, ::Float::DIG)
|
|
21
|
+
when ::String
|
|
22
|
+
coerce_string(value)
|
|
23
|
+
when ::Rational
|
|
24
|
+
BigDecimal(value, ::Float::DIG)
|
|
25
|
+
else
|
|
26
|
+
COERCION_FAILED
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def valid?(value)
|
|
31
|
+
value.is_a?(::BigDecimal) && value.finite?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def type_name
|
|
35
|
+
"decimal"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def coerce_string(value)
|
|
41
|
+
stripped = value.strip
|
|
42
|
+
return COERCION_FAILED if stripped.empty?
|
|
43
|
+
|
|
44
|
+
# Validate format before conversion
|
|
45
|
+
return COERCION_FAILED unless stripped.match?(/\A-?\d+(\.\d+)?\z/)
|
|
46
|
+
|
|
47
|
+
result = BigDecimal(stripped)
|
|
48
|
+
result.finite? ? result : COERCION_FAILED
|
|
49
|
+
rescue ArgumentError
|
|
50
|
+
COERCION_FAILED
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
register(:decimal, Decimal)
|
|
55
|
+
register(:bigdecimal, Decimal)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# Discriminated union type - selects schema based on discriminator field
|
|
6
|
+
# More efficient than regular unions for object types
|
|
7
|
+
class DiscriminatedUnion < Base
|
|
8
|
+
attr_reader :discriminator, :mapping
|
|
9
|
+
|
|
10
|
+
# @param discriminator [Symbol] The field to use as discriminator
|
|
11
|
+
# @param mapping [Hash<value, Schema>] Maps discriminator values to schemas
|
|
12
|
+
def initialize(discriminator:, mapping:, **options)
|
|
13
|
+
@discriminator = discriminator.to_sym
|
|
14
|
+
@mapping = mapping.transform_keys { |k| k.is_a?(Symbol) ? k.to_s : k }.freeze
|
|
15
|
+
super(**options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(value, path: [])
|
|
19
|
+
unless value.is_a?(Hash)
|
|
20
|
+
return [nil, [Error.new(path: path, message: "must be an object", code: :type_error)]]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Normalize input
|
|
24
|
+
normalized = value.transform_keys { |k| k.is_a?(Symbol) ? k.to_s : k }
|
|
25
|
+
disc_value = normalized[@discriminator.to_s]
|
|
26
|
+
|
|
27
|
+
if disc_value.nil?
|
|
28
|
+
return [nil, [Error.new(
|
|
29
|
+
path: path + [@discriminator],
|
|
30
|
+
message: "discriminator field is required",
|
|
31
|
+
code: :discriminator_missing
|
|
32
|
+
)]]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Convert symbol to string for lookup
|
|
36
|
+
lookup_value = disc_value.is_a?(Symbol) ? disc_value.to_s : disc_value
|
|
37
|
+
schema = @mapping[lookup_value]
|
|
38
|
+
|
|
39
|
+
if schema.nil?
|
|
40
|
+
valid_values = @mapping.keys.map(&:inspect).join(", ")
|
|
41
|
+
return [nil, [Error.new(
|
|
42
|
+
path: path + [@discriminator],
|
|
43
|
+
message: "must be one of: #{valid_values}",
|
|
44
|
+
code: :invalid_discriminator
|
|
45
|
+
)]]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Validate with the selected schema
|
|
49
|
+
result = schema.safe_parse(value, path_prefix: path)
|
|
50
|
+
|
|
51
|
+
if result.success?
|
|
52
|
+
[result.data, []]
|
|
53
|
+
else
|
|
54
|
+
[nil, result.errors.to_a]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def coerce(value)
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def valid?(value)
|
|
63
|
+
value.is_a?(Hash)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def type_name
|
|
67
|
+
values = @mapping.keys.map(&:inspect).join(" | ")
|
|
68
|
+
"discriminated_union<#{@discriminator}: #{values}>"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
register(:discriminated_union, DiscriminatedUnion)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# Float type with coercion from String/Integer
|
|
6
|
+
class Float < Base
|
|
7
|
+
def coerce(value)
|
|
8
|
+
case value
|
|
9
|
+
when ::Float
|
|
10
|
+
return COERCION_FAILED unless value.finite?
|
|
11
|
+
|
|
12
|
+
value
|
|
13
|
+
when ::Integer
|
|
14
|
+
value.to_f
|
|
15
|
+
when ::String
|
|
16
|
+
coerce_string(value)
|
|
17
|
+
else
|
|
18
|
+
COERCION_FAILED
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def valid?(value)
|
|
23
|
+
value.is_a?(::Float) && value.finite?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def type_name
|
|
27
|
+
"float"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def coerce_string(value)
|
|
33
|
+
stripped = value.strip
|
|
34
|
+
return COERCION_FAILED if stripped.empty?
|
|
35
|
+
|
|
36
|
+
# Match integer or float format
|
|
37
|
+
return COERCION_FAILED unless stripped.match?(/\A-?\d+(\.\d+)?\z/)
|
|
38
|
+
|
|
39
|
+
result = stripped.to_f
|
|
40
|
+
result.finite? ? result : COERCION_FAILED
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
register(:float, Float)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# Integer type with coercion from String/Float
|
|
6
|
+
class Integer < Base
|
|
7
|
+
def coerce(value)
|
|
8
|
+
case value
|
|
9
|
+
when ::Integer
|
|
10
|
+
value
|
|
11
|
+
when ::Float
|
|
12
|
+
# Only coerce whole numbers
|
|
13
|
+
return COERCION_FAILED unless value.finite? && value == value.to_i
|
|
14
|
+
|
|
15
|
+
value.to_i
|
|
16
|
+
when ::String
|
|
17
|
+
coerce_string(value)
|
|
18
|
+
else
|
|
19
|
+
COERCION_FAILED
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def valid?(value)
|
|
24
|
+
value.is_a?(::Integer)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def type_name
|
|
28
|
+
"integer"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def coerce_string(value)
|
|
34
|
+
stripped = value.strip
|
|
35
|
+
return COERCION_FAILED if stripped.empty?
|
|
36
|
+
|
|
37
|
+
# Try integer parse first
|
|
38
|
+
if stripped.match?(/\A-?\d+\z/)
|
|
39
|
+
return stripped.to_i
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Try float parse for strings like "42.0"
|
|
43
|
+
if stripped.match?(/\A-?\d+\.0+\z/)
|
|
44
|
+
return stripped.to_f.to_i
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
COERCION_FAILED
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
register(:integer, Integer)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# Literal type for exact value matching
|
|
6
|
+
# Accepts only specific values (like TypeScript literal types)
|
|
7
|
+
class Literal < Base
|
|
8
|
+
attr_reader :values
|
|
9
|
+
|
|
10
|
+
def initialize(values:, **options)
|
|
11
|
+
@values = Array(values).freeze
|
|
12
|
+
super(**options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def coerce(value)
|
|
16
|
+
# No coercion for literals - must match exactly
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def valid?(value)
|
|
21
|
+
@values.include?(value)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def type_name
|
|
25
|
+
if @values.size == 1
|
|
26
|
+
@values.first.inspect
|
|
27
|
+
else
|
|
28
|
+
@values.map(&:inspect).join(" | ")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def coercion_error_message(value)
|
|
33
|
+
"must be #{type_name}, got #{value.inspect}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validation_error_message(value)
|
|
37
|
+
"must be #{type_name}, got #{value.inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
register(:literal, Literal)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# Object type for nested schema validation
|
|
6
|
+
class Object < Base
|
|
7
|
+
attr_reader :schema
|
|
8
|
+
|
|
9
|
+
def initialize(schema: nil, **options)
|
|
10
|
+
@schema = schema
|
|
11
|
+
super(**options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def coerce(value)
|
|
15
|
+
return COERCION_FAILED unless value.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def valid?(value)
|
|
21
|
+
value.is_a?(Hash)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Override call to delegate to nested schema
|
|
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 @schema
|
|
33
|
+
|
|
34
|
+
# Delegate to nested schema with path prefix
|
|
35
|
+
result = @schema.safe_parse(coerced, path_prefix: path)
|
|
36
|
+
|
|
37
|
+
if result.success?
|
|
38
|
+
[result.data, []]
|
|
39
|
+
else
|
|
40
|
+
[nil, result.errors.to_a]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def type_name
|
|
45
|
+
"object"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
register(:object, Object)
|
|
50
|
+
register(:hash, Object)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# String type with coercion from Symbol/Numeric
|
|
6
|
+
class String < Base
|
|
7
|
+
def coerce(value)
|
|
8
|
+
case value
|
|
9
|
+
when ::String
|
|
10
|
+
value
|
|
11
|
+
when Symbol, Numeric
|
|
12
|
+
value.to_s
|
|
13
|
+
else
|
|
14
|
+
COERCION_FAILED
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def valid?(value)
|
|
19
|
+
value.is_a?(::String)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def type_name
|
|
23
|
+
"string"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
register(:string, String)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Validrb
|
|
6
|
+
module Types
|
|
7
|
+
# Time type with coercion from String (ISO8601) and Date/DateTime
|
|
8
|
+
class Time < Base
|
|
9
|
+
def coerce(value)
|
|
10
|
+
case value
|
|
11
|
+
when ::Time
|
|
12
|
+
value
|
|
13
|
+
when ::DateTime
|
|
14
|
+
value.to_time
|
|
15
|
+
when ::Date
|
|
16
|
+
value.to_time
|
|
17
|
+
when ::String
|
|
18
|
+
coerce_string(value)
|
|
19
|
+
when ::Integer
|
|
20
|
+
# Unix timestamp
|
|
21
|
+
::Time.at(value)
|
|
22
|
+
when ::Float
|
|
23
|
+
# Unix timestamp with fractional seconds
|
|
24
|
+
::Time.at(value)
|
|
25
|
+
else
|
|
26
|
+
COERCION_FAILED
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def valid?(value)
|
|
31
|
+
value.is_a?(::Time)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def type_name
|
|
35
|
+
"time"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def coerce_string(value)
|
|
41
|
+
stripped = value.strip
|
|
42
|
+
return COERCION_FAILED if stripped.empty?
|
|
43
|
+
|
|
44
|
+
# Try ISO8601 first
|
|
45
|
+
begin
|
|
46
|
+
return ::Time.iso8601(stripped)
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
# Continue
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Try RFC2822
|
|
52
|
+
begin
|
|
53
|
+
return ::Time.rfc2822(stripped)
|
|
54
|
+
rescue ArgumentError
|
|
55
|
+
# Continue
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Try Ruby's flexible parsing
|
|
59
|
+
begin
|
|
60
|
+
return ::Time.parse(stripped)
|
|
61
|
+
rescue ArgumentError
|
|
62
|
+
COERCION_FAILED
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
register(:time, Time)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Types
|
|
5
|
+
# Union type that accepts any of the specified types
|
|
6
|
+
class Union < Base
|
|
7
|
+
attr_reader :types
|
|
8
|
+
|
|
9
|
+
def initialize(types:, **options)
|
|
10
|
+
@types = resolve_types(types)
|
|
11
|
+
super(**options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def coerce(value)
|
|
15
|
+
# Try each type in order, return first successful coercion
|
|
16
|
+
@types.each do |type|
|
|
17
|
+
result = type.coerce(value)
|
|
18
|
+
return result unless result.equal?(COERCION_FAILED)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
COERCION_FAILED
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def valid?(value)
|
|
25
|
+
@types.any? { |type| type.valid?(value) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Override call to try each type and return first success
|
|
29
|
+
def call(value, path: [])
|
|
30
|
+
errors = []
|
|
31
|
+
|
|
32
|
+
@types.each do |type|
|
|
33
|
+
coerced, type_errors = type.call(value, path: path)
|
|
34
|
+
return [coerced, []] if type_errors.empty?
|
|
35
|
+
|
|
36
|
+
errors.concat(type_errors)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# All types failed - return a union-specific error
|
|
40
|
+
[nil, [Error.new(
|
|
41
|
+
path: path,
|
|
42
|
+
message: "must be one of: #{type_names.join(", ")}",
|
|
43
|
+
code: :union_type_error
|
|
44
|
+
)]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def type_name
|
|
48
|
+
"union<#{type_names.join(" | ")}>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def resolve_types(types)
|
|
54
|
+
types.map do |type|
|
|
55
|
+
case type
|
|
56
|
+
when Symbol
|
|
57
|
+
Types.build(type)
|
|
58
|
+
when Types::Base
|
|
59
|
+
type
|
|
60
|
+
when Class
|
|
61
|
+
type.new
|
|
62
|
+
else
|
|
63
|
+
raise ArgumentError, "Invalid type in union: #{type.inspect}"
|
|
64
|
+
end
|
|
65
|
+
end.freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def type_names
|
|
69
|
+
@types.map(&:type_name)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
register(:union, Union)
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/validrb.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "validrb/version"
|
|
4
|
+
require_relative "validrb/i18n"
|
|
5
|
+
require_relative "validrb/errors"
|
|
6
|
+
require_relative "validrb/result"
|
|
7
|
+
require_relative "validrb/context"
|
|
8
|
+
require_relative "validrb/constraints/base"
|
|
9
|
+
require_relative "validrb/constraints/min"
|
|
10
|
+
require_relative "validrb/constraints/max"
|
|
11
|
+
require_relative "validrb/constraints/length"
|
|
12
|
+
require_relative "validrb/constraints/format"
|
|
13
|
+
require_relative "validrb/constraints/enum"
|
|
14
|
+
require_relative "validrb/types/base"
|
|
15
|
+
require_relative "validrb/types/string"
|
|
16
|
+
require_relative "validrb/types/integer"
|
|
17
|
+
require_relative "validrb/types/float"
|
|
18
|
+
require_relative "validrb/types/boolean"
|
|
19
|
+
require_relative "validrb/types/array"
|
|
20
|
+
require_relative "validrb/types/object"
|
|
21
|
+
require_relative "validrb/types/date"
|
|
22
|
+
require_relative "validrb/types/datetime"
|
|
23
|
+
require_relative "validrb/types/time"
|
|
24
|
+
require_relative "validrb/types/decimal"
|
|
25
|
+
require_relative "validrb/types/union"
|
|
26
|
+
require_relative "validrb/types/literal"
|
|
27
|
+
require_relative "validrb/types/discriminated_union"
|
|
28
|
+
require_relative "validrb/field"
|
|
29
|
+
require_relative "validrb/schema"
|
|
30
|
+
require_relative "validrb/custom_type"
|
|
31
|
+
require_relative "validrb/introspection"
|
|
32
|
+
require_relative "validrb/serializer"
|
|
33
|
+
require_relative "validrb/openapi"
|
|
34
|
+
|
|
35
|
+
module Validrb
|
|
36
|
+
class << self
|
|
37
|
+
# Main entry point for creating schemas
|
|
38
|
+
# Options:
|
|
39
|
+
# strict: true - raise error on unknown keys
|
|
40
|
+
# passthrough: true - keep unknown keys in output
|
|
41
|
+
def schema(**options, &block)
|
|
42
|
+
Schema.new(**options, &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Configure I18n settings
|
|
46
|
+
def configure_i18n(&block)
|
|
47
|
+
I18n.configure(&block)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Create a validation context
|
|
51
|
+
def context(**data)
|
|
52
|
+
Context.new(**data)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|