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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ VERSION = "0.5.0"
5
+ 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