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,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
|