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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +245 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +5 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +852 -0
  8. data/README_CN.md +852 -0
  9. data/Rakefile +12 -0
  10. data/lib/rbdantic/base/access.rb +105 -0
  11. data/lib/rbdantic/base/dsl.rb +79 -0
  12. data/lib/rbdantic/base/validation.rb +152 -0
  13. data/lib/rbdantic/base.rb +30 -0
  14. data/lib/rbdantic/config.rb +60 -0
  15. data/lib/rbdantic/error_detail.rb +54 -0
  16. data/lib/rbdantic/field.rb +188 -0
  17. data/lib/rbdantic/json_schema/defs_registry.rb +79 -0
  18. data/lib/rbdantic/json_schema/generator.rb +148 -0
  19. data/lib/rbdantic/json_schema/types.rb +98 -0
  20. data/lib/rbdantic/serialization/dumper.rb +133 -0
  21. data/lib/rbdantic/serialization/json_serializer.rb +60 -0
  22. data/lib/rbdantic/validators/field_validator.rb +83 -0
  23. data/lib/rbdantic/validators/model_validator.rb +59 -0
  24. data/lib/rbdantic/validators/types/array.rb +77 -0
  25. data/lib/rbdantic/validators/types/base.rb +78 -0
  26. data/lib/rbdantic/validators/types/boolean.rb +37 -0
  27. data/lib/rbdantic/validators/types/float.rb +32 -0
  28. data/lib/rbdantic/validators/types/hash.rb +54 -0
  29. data/lib/rbdantic/validators/types/integer.rb +28 -0
  30. data/lib/rbdantic/validators/types/model.rb +75 -0
  31. data/lib/rbdantic/validators/types/number.rb +63 -0
  32. data/lib/rbdantic/validators/types/string.rb +70 -0
  33. data/lib/rbdantic/validators/types/symbol.rb +30 -0
  34. data/lib/rbdantic/validators/types/time.rb +33 -0
  35. data/lib/rbdantic/validators/types.rb +63 -0
  36. data/lib/rbdantic/validators/validator_context.rb +43 -0
  37. data/lib/rbdantic/version.rb +5 -0
  38. data/lib/rbdantic.rb +8 -0
  39. data/sig/rbdantic.rbs +4 -0
  40. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbdantic
4
+ VERSION = "0.1.0"
5
+ end