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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ module Constraints
5
+ # Validates value is in an allowed list
6
+ class Enum < Base
7
+ attr_reader :allowed
8
+ alias values allowed
9
+
10
+ def initialize(allowed)
11
+ @allowed = Array(allowed).freeze
12
+ raise ArgumentError, "Enum requires at least one allowed value" if @allowed.empty?
13
+
14
+ super()
15
+ end
16
+
17
+ def valid?(input)
18
+ @allowed.include?(input)
19
+ end
20
+
21
+ def error_message(_input)
22
+ formatted = @allowed.map(&:inspect).join(", ")
23
+ "must be one of: #{formatted}"
24
+ end
25
+
26
+ def error_code
27
+ :enum
28
+ end
29
+ end
30
+
31
+ register(:enum, Enum)
32
+ end
33
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ module Constraints
5
+ # Validates value matches a regex or named format
6
+ class Format < Base
7
+ NAMED_FORMATS = {
8
+ email: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i,
9
+ url: %r{\Ahttps?://[^\s/$.?#].[^\s]*\z}i,
10
+ uuid: /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i,
11
+ phone: /\A\+?[\d\s\-().]{7,}\z/,
12
+ alphanumeric: /\A[a-zA-Z0-9]+\z/,
13
+ alpha: /\A[a-zA-Z]+\z/,
14
+ numeric: /\A\d+\z/,
15
+ hex: /\A[0-9a-fA-F]+\z/,
16
+ slug: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
17
+ }.freeze
18
+
19
+ attr_reader :pattern, :format_name
20
+
21
+ def initialize(pattern)
22
+ @format_name = pattern.is_a?(Symbol) ? pattern : nil
23
+ @pattern = resolve_pattern(pattern)
24
+ super()
25
+ end
26
+
27
+ def valid?(input)
28
+ return false unless input.is_a?(String)
29
+
30
+ @pattern.match?(input)
31
+ end
32
+
33
+ def error_message(_input)
34
+ if @format_name
35
+ "must be a valid #{@format_name}"
36
+ else
37
+ "must match format #{@pattern.inspect}"
38
+ end
39
+ end
40
+
41
+ def error_code
42
+ :format
43
+ end
44
+
45
+ private
46
+
47
+ def resolve_pattern(pattern)
48
+ case pattern
49
+ when Regexp
50
+ pattern
51
+ when Symbol
52
+ NAMED_FORMATS.fetch(pattern) do
53
+ raise ArgumentError, "Unknown format: #{pattern}. Available: #{NAMED_FORMATS.keys.join(", ")}"
54
+ end
55
+ else
56
+ raise ArgumentError, "Format must be a Regexp or Symbol, got #{pattern.class}"
57
+ end
58
+ end
59
+ end
60
+
61
+ register(:format, Format)
62
+ end
63
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ module Constraints
5
+ # Validates exact length, range, or min/max length
6
+ class Length < Base
7
+ attr_reader :exact, :min, :max, :range
8
+
9
+ def initialize(exact: nil, min: nil, max: nil, range: nil)
10
+ @exact = exact
11
+ @min = min
12
+ @max = max
13
+ @range = range
14
+ validate_options!
15
+ super()
16
+ end
17
+
18
+ def valid?(input)
19
+ return false unless input.respond_to?(:length)
20
+
21
+ len = input.length
22
+
23
+ if @exact
24
+ len == @exact
25
+ elsif @range
26
+ @range.include?(len)
27
+ else
28
+ (@min.nil? || len >= @min) && (@max.nil? || len <= @max)
29
+ end
30
+ end
31
+
32
+ def error_message(input)
33
+ len = input.respond_to?(:length) ? input.length : "N/A"
34
+
35
+ if @exact
36
+ "length must be exactly #{@exact} (got #{len})"
37
+ elsif @range
38
+ "length must be between #{@range.min} and #{@range.max} (got #{len})"
39
+ elsif @min && @max
40
+ "length must be between #{@min} and #{@max} (got #{len})"
41
+ elsif @min
42
+ "length must be at least #{@min} (got #{len})"
43
+ else
44
+ "length must be at most #{@max} (got #{len})"
45
+ end
46
+ end
47
+
48
+ def error_code
49
+ :length
50
+ end
51
+
52
+ def options
53
+ {
54
+ exact: @exact,
55
+ min: @min,
56
+ max: @max,
57
+ range: @range
58
+ }.compact
59
+ end
60
+
61
+ private
62
+
63
+ def validate_options!
64
+ return if @exact || @min || @max || @range
65
+
66
+ raise ArgumentError, "Length constraint requires at least one of: exact, min, max, or range"
67
+ end
68
+ end
69
+
70
+ register(:length, Length)
71
+ end
72
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ module Constraints
5
+ # Validates maximum value (numbers) or maximum length (strings/arrays)
6
+ class Max < Base
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ @value = value
11
+ super()
12
+ end
13
+
14
+ def valid?(input)
15
+ comparable_value(input) <= @value
16
+ end
17
+
18
+ def error_message(input)
19
+ if length_based?(input)
20
+ "length must be at most #{@value} (got #{input.length})"
21
+ else
22
+ "must be at most #{@value}"
23
+ end
24
+ end
25
+
26
+ def error_code
27
+ :max
28
+ end
29
+
30
+ private
31
+
32
+ def comparable_value(input)
33
+ length_based?(input) ? input.length : input
34
+ end
35
+
36
+ def length_based?(input)
37
+ input.respond_to?(:length) && !input.is_a?(Numeric)
38
+ end
39
+ end
40
+
41
+ register(:max, Max)
42
+ end
43
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ module Constraints
5
+ # Validates minimum value (numbers) or minimum length (strings/arrays)
6
+ class Min < Base
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ @value = value
11
+ super()
12
+ end
13
+
14
+ def valid?(input)
15
+ comparable_value(input) >= @value
16
+ end
17
+
18
+ def error_message(input)
19
+ if length_based?(input)
20
+ "length must be at least #{@value} (got #{input.length})"
21
+ else
22
+ "must be at least #{@value}"
23
+ end
24
+ end
25
+
26
+ def error_code
27
+ :min
28
+ end
29
+
30
+ private
31
+
32
+ def comparable_value(input)
33
+ length_based?(input) ? input.length : input
34
+ end
35
+
36
+ def length_based?(input)
37
+ input.respond_to?(:length) && !input.is_a?(Numeric)
38
+ end
39
+ end
40
+
41
+ register(:min, Min)
42
+ end
43
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ # Validation context - carries additional data through the validation pipeline
5
+ # Useful for passing request context, current user, locale, etc.
6
+ class Context
7
+ attr_reader :data
8
+
9
+ def initialize(**data)
10
+ @data = data.freeze
11
+ freeze
12
+ end
13
+
14
+ def [](key)
15
+ @data[key.to_sym]
16
+ end
17
+
18
+ def key?(key)
19
+ @data.key?(key.to_sym)
20
+ end
21
+
22
+ def fetch(key, *args, &block)
23
+ @data.fetch(key.to_sym, *args, &block)
24
+ end
25
+
26
+ def to_h
27
+ @data.dup
28
+ end
29
+
30
+ # Empty context singleton
31
+ EMPTY = new.freeze
32
+
33
+ def self.empty
34
+ EMPTY
35
+ end
36
+
37
+ def empty?
38
+ @data.empty?
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ # DSL for defining custom types easily
5
+ # Example:
6
+ # Validrb.define_type(:email) do
7
+ # coerce { |v| v.to_s.strip.downcase }
8
+ # validate { |v| v.match?(/\A[^@\s]+@[^@\s]+\z/) }
9
+ # error_message { |v| "must be a valid email address" }
10
+ # end
11
+ module CustomType
12
+ class Builder
13
+ def initialize
14
+ @coercer = nil
15
+ @validator = nil
16
+ @error_message_proc = nil
17
+ @type_name = nil
18
+ end
19
+
20
+ # Define coercion logic
21
+ def coerce(&block)
22
+ @coercer = block
23
+ end
24
+
25
+ # Define validation logic
26
+ def validate(&block)
27
+ @validator = block
28
+ end
29
+
30
+ # Define custom error message
31
+ def error_message(&block)
32
+ @error_message_proc = block
33
+ end
34
+
35
+ # Set the type name for error messages
36
+ def name(type_name)
37
+ @type_name = type_name
38
+ end
39
+
40
+ def build(type_sym)
41
+ coercer = @coercer
42
+ validator = @validator
43
+ error_proc = @error_message_proc
44
+ type_name_val = @type_name || type_sym.to_s
45
+
46
+ Class.new(Types::Base) do
47
+ define_method(:coerce) do |value|
48
+ return value unless coercer
49
+
50
+ begin
51
+ coercer.call(value)
52
+ rescue StandardError
53
+ Types::COERCION_FAILED
54
+ end
55
+ end
56
+
57
+ define_method(:valid?) do |value|
58
+ return true unless validator
59
+
60
+ validator.call(value)
61
+ end
62
+
63
+ define_method(:type_name) do
64
+ type_name_val
65
+ end
66
+
67
+ if error_proc
68
+ define_method(:validation_error_message) do |value|
69
+ error_proc.call(value)
70
+ end
71
+
72
+ define_method(:coercion_error_message) do |value|
73
+ error_proc.call(value)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def self.define(type_sym, &block)
81
+ builder = Builder.new
82
+ builder.instance_eval(&block)
83
+ klass = builder.build(type_sym)
84
+ Types.register(type_sym, klass)
85
+ klass
86
+ end
87
+ end
88
+
89
+ class << self
90
+ # Public API for defining custom types
91
+ def define_type(type_sym, &block)
92
+ CustomType.define(type_sym, &block)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ # Represents a single validation error with path tracking
5
+ class Error
6
+ attr_reader :path, :message, :code
7
+
8
+ def initialize(path:, message:, code: nil)
9
+ @path = Array(path).freeze
10
+ @message = message.freeze
11
+ @code = code&.to_sym
12
+ freeze
13
+ end
14
+
15
+ def full_path
16
+ return "" if path.empty?
17
+
18
+ path.map(&:to_s).join(".")
19
+ end
20
+
21
+ def to_s
22
+ prefix = full_path.empty? ? "" : "#{full_path}: "
23
+ "#{prefix}#{message}"
24
+ end
25
+
26
+ def to_h
27
+ { path: path, message: message, code: code }.compact
28
+ end
29
+
30
+ def ==(other)
31
+ other.is_a?(Error) &&
32
+ path == other.path &&
33
+ message == other.message &&
34
+ code == other.code
35
+ end
36
+ alias eql? ==
37
+
38
+ def hash
39
+ [path, message, code].hash
40
+ end
41
+ end
42
+
43
+ # Collection of validation errors with utility methods
44
+ class ErrorCollection
45
+ include Enumerable
46
+
47
+ def initialize(errors = [])
48
+ @errors = errors.dup.freeze
49
+ freeze
50
+ end
51
+
52
+ def each(&block)
53
+ @errors.each(&block)
54
+ end
55
+
56
+ def [](index)
57
+ @errors[index]
58
+ end
59
+
60
+ def size
61
+ @errors.size
62
+ end
63
+ alias length size
64
+ alias count size
65
+
66
+ def empty?
67
+ @errors.empty?
68
+ end
69
+
70
+ def any?
71
+ @errors.any?
72
+ end
73
+
74
+ def add(error)
75
+ ErrorCollection.new(@errors + [error])
76
+ end
77
+
78
+ def merge(other)
79
+ ErrorCollection.new(@errors + other.to_a)
80
+ end
81
+
82
+ def for_path(*path)
83
+ path = path.flatten
84
+ ErrorCollection.new(@errors.select { |e| e.path[0...path.size] == path })
85
+ end
86
+
87
+ def messages
88
+ @errors.map(&:message)
89
+ end
90
+
91
+ def full_messages
92
+ @errors.map(&:to_s)
93
+ end
94
+
95
+ def to_a
96
+ @errors.dup
97
+ end
98
+
99
+ def to_h
100
+ @errors.group_by(&:full_path).transform_values { |errs| errs.map(&:message) }
101
+ end
102
+ end
103
+
104
+ # Exception raised when parse() fails validation
105
+ class ValidationError < StandardError
106
+ attr_reader :errors
107
+
108
+ def initialize(errors)
109
+ @errors = errors.is_a?(ErrorCollection) ? errors : ErrorCollection.new(Array(errors))
110
+ super(build_message)
111
+ end
112
+
113
+ private
114
+
115
+ def build_message
116
+ msgs = @errors.full_messages
117
+ return "Validation failed" if msgs.empty?
118
+
119
+ "Validation failed: #{msgs.join("; ")}"
120
+ end
121
+ end
122
+ end