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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Rbdantic
6
+ module JsonSchema
7
+ # Registry for tracking model schemas during generation
8
+ # Used to implement $defs/$ref pattern for circular references
9
+ class DefsRegistry
10
+ def initialize
11
+ @defs = {}
12
+ @being_processed = Set.new # Currently being processed (for cycle detection)
13
+ @referenced = Set.new
14
+ end
15
+
16
+ def key_for(model_class)
17
+ model_class.name || "AnonymousModel_#{model_class.object_id}"
18
+ end
19
+
20
+ # Check if a model is currently being processed (for cycle detection)
21
+ # @param model_class [Class] the model class
22
+ # @return [Boolean]
23
+ def being_processed?(model_class)
24
+ @being_processed.include?(model_class)
25
+ end
26
+
27
+ # Mark a model as being processed
28
+ # @param model_class [Class] the model class
29
+ def mark_processing(model_class)
30
+ @being_processed.add(model_class)
31
+ end
32
+
33
+ # Register a model's completed schema and return its key
34
+ # @param model_class [Class] the model class
35
+ # @param schema [Hash] the generated schema
36
+ # @return [String] the key used in $defs
37
+ def register(model_class, schema)
38
+ key = key_for(model_class)
39
+ @defs[key] = schema
40
+ @being_processed.delete(model_class) # No longer being processed
41
+ key
42
+ end
43
+
44
+ # Check if a model's schema is registered (completed)
45
+ # @param model_class [Class] the model class
46
+ # @return [Boolean]
47
+ def registered?(model_class)
48
+ @defs.key?(key_for(model_class))
49
+ end
50
+
51
+ def referenced?(model_class)
52
+ @referenced.include?(key_for(model_class))
53
+ end
54
+
55
+ # Generate a $ref for a registered model
56
+ # @param model_class [Class] the model class
57
+ # @return [Hash] the $ref object
58
+ def ref_for(model_class)
59
+ key = key_for(model_class)
60
+ @referenced.add(key)
61
+ { "$ref" => "#/$defs/#{key}" }
62
+ end
63
+
64
+ # Get the $defs hash for inclusion in top-level schema
65
+ # @return [Hash, nil] the defs hash or nil if empty
66
+ def defs_hash(except: nil)
67
+ defs = except ? @defs.reject { |key, _| key == key_for(except) } : @defs
68
+ defs.empty? ? nil : defs
69
+ end
70
+
71
+ # Clear the registry (for fresh generation)
72
+ def clear
73
+ @defs.clear
74
+ @being_processed.clear
75
+ @referenced.clear
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "defs_registry"
4
+
5
+ module Rbdantic
6
+ module JsonSchema
7
+ # Generate JSON Schema from model class
8
+ class Generator
9
+ # JSON Schema version
10
+ SCHEMA_VERSION = "https://json-schema.org/draft/2020-12/schema"
11
+
12
+ # Generate schema for a model class
13
+ # @param model_class [Class] the model class
14
+ # @param options [Hash] generation options
15
+ # @option options [String] :title optional title (defaults to class name)
16
+ # @option options [String] :description optional description
17
+ # @option options [String] :schema_id optional $id for the schema
18
+ # @option options [Boolean] :include_defaults include default values in schema
19
+ # @option options [DefsRegistry] :defs_registry registry for $defs/$ref pattern
20
+ # @return [Hash] JSON Schema
21
+ def self.generate(model_class, **options)
22
+ new(model_class, **options).generate
23
+ end
24
+
25
+ def initialize(model_class, title: nil, description: nil, schema_id: nil,
26
+ include_defaults: true, top_level: true, defs_registry: nil, by_alias: false)
27
+ @model_class = model_class
28
+ @title = title || model_class.name
29
+ @description = description
30
+ @schema_id = schema_id
31
+ @include_defaults = include_defaults
32
+ @top_level = top_level
33
+ @by_alias = by_alias
34
+ # Use provided registry or create one at top level
35
+ @defs_registry = defs_registry || (top_level ? DefsRegistry.new : nil)
36
+ end
37
+
38
+ def generate
39
+ # Handle circular references - if being processed, return $ref
40
+ if @defs_registry && @defs_registry.being_processed?(@model_class)
41
+ return @defs_registry.ref_for(@model_class)
42
+ end
43
+
44
+ schema = {}
45
+
46
+ # Only add $schema and $id at top level
47
+ if @top_level
48
+ schema["$schema"] = SCHEMA_VERSION
49
+ schema["$id"] = @schema_id if @schema_id
50
+ end
51
+
52
+ schema["type"] = "object"
53
+
54
+ # Add optional metadata
55
+ schema["title"] = @title if @title && @top_level
56
+ schema["description"] = @description if @description
57
+
58
+ # Mark as being processed before processing fields (for cycle detection)
59
+ if @defs_registry
60
+ @defs_registry.mark_processing(@model_class)
61
+ end
62
+
63
+ # Generate properties
64
+ properties = {}
65
+ required = []
66
+
67
+ @model_class.fields.each do |name, field_info|
68
+ property_name = schema_property_name(name, field_info)
69
+ properties[property_name] = generate_property(field_info)
70
+ required << property_name if field_info.required?
71
+ end
72
+
73
+ schema["properties"] = properties
74
+ schema["required"] = required if required.any?
75
+
76
+ # Create a copy of schema for $defs (without $defs key to avoid circular JSON)
77
+ defs_schema = {
78
+ "type" => "object",
79
+ "title" => @title,
80
+ "description" => @description,
81
+ "properties" => properties
82
+ }.compact
83
+ defs_schema["required"] = required if required.any?
84
+
85
+ # Register this model's schema in defs registry
86
+ if @defs_registry
87
+ @defs_registry.register(@model_class, defs_schema)
88
+ end
89
+
90
+ # Add $defs at top level if we have referenced models
91
+ if @top_level && @defs_registry
92
+ defs = if @defs_registry.referenced?(@model_class)
93
+ @defs_registry.defs_hash
94
+ else
95
+ @defs_registry.defs_hash(except: @model_class)
96
+ end
97
+ if defs && defs.any?
98
+ schema["$defs"] = defs
99
+ end
100
+ end
101
+
102
+ schema
103
+ end
104
+
105
+ private
106
+
107
+ def generate_property(field_info)
108
+ schema = Types.to_schema(
109
+ field_info.type,
110
+ **field_info.constraints,
111
+ defs_registry: @defs_registry,
112
+ by_alias: @by_alias
113
+ )
114
+
115
+ # Handle optional fields - allow null
116
+ schema = handle_optional(schema) if field_info.optional
117
+
118
+ # Include default value if present and not a factory
119
+ if @include_defaults && field_info.has_default? && !field_info.default_factory
120
+ schema["default"] = field_info.default
121
+ end
122
+
123
+ schema
124
+ end
125
+
126
+ def handle_optional(schema)
127
+ if schema["$ref"]
128
+ # For $ref, use oneOf to allow null
129
+ { "oneOf" => [schema, { "type" => "null" }] }
130
+ elsif schema["type"].is_a?(Array)
131
+ schema["type"] = schema["type"] + ["null"]
132
+ schema
133
+ else
134
+ schema["type"] = [schema["type"], "null"]
135
+ schema
136
+ end
137
+ end
138
+
139
+ def schema_property_name(name, field_info)
140
+ if @by_alias && field_info.alias_name
141
+ field_info.alias_name.to_s
142
+ else
143
+ name.to_s
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbdantic
4
+ module JsonSchema
5
+ # Type-to-schema mappings
6
+ module Types
7
+ # Map Ruby type to JSON Schema type
8
+ # @param type [Class] Ruby type
9
+ # @param constraints [Hash] field constraints
10
+ # @param defs_registry [DefsRegistry] registry for $defs/$ref pattern
11
+ # @return [Hash] JSON Schema for the type
12
+ def self.to_schema(type, **constraints)
13
+ defs_registry = constraints[:defs_registry]
14
+ by_alias = constraints[:by_alias]
15
+ constraints = constraints.reject { |k, _| k == :defs_registry || k == :by_alias }
16
+
17
+ schema = base_schema(type, defs_registry: defs_registry, by_alias: by_alias)
18
+
19
+ # Add constraints
20
+ add_string_constraints(schema, constraints) if type == ::String
21
+ add_numeric_constraints(schema, constraints) if type == ::Integer || type == ::Float
22
+ add_array_constraints(schema, constraints, defs_registry: defs_registry, by_alias: by_alias) if type == ::Array
23
+ add_hash_constraints(schema, constraints) if type == ::Hash
24
+
25
+ schema
26
+ end
27
+
28
+ def self.base_schema(type, defs_registry: nil, by_alias: false)
29
+ # Use direct class comparison (type == Class), not === (which checks instance type)
30
+ if type == ::String
31
+ { "type" => "string" }
32
+ elsif type == ::Integer
33
+ { "type" => "integer" }
34
+ elsif type == ::Float
35
+ { "type" => "number" }
36
+ elsif type == ::Time
37
+ { "type" => "string", "format" => "date-time" }
38
+ elsif type == ::Rbdantic::Boolean
39
+ { "type" => "boolean" }
40
+ elsif type == ::Array
41
+ { "type" => "array" }
42
+ elsif type == ::Hash
43
+ { "type" => "object" }
44
+ elsif type.is_a?(Class) && type < ::Rbdantic::BaseModel
45
+ if defs_registry
46
+ unless defs_registry.registered?(type) || defs_registry.being_processed?(type)
47
+ Generator.generate(type, top_level: false, defs_registry: defs_registry, by_alias: by_alias)
48
+ end
49
+
50
+ defs_registry.ref_for(type)
51
+ else
52
+ Generator.generate(type, top_level: false, defs_registry: nil, by_alias: by_alias)
53
+ end
54
+ else
55
+ { "type" => "object" }
56
+ end
57
+ end
58
+
59
+ def self.add_string_constraints(schema, constraints)
60
+ schema["minLength"] = constraints[:min_length] if constraints[:min_length]
61
+ schema["maxLength"] = constraints[:max_length] if constraints[:max_length]
62
+ schema["pattern"] = constraints[:pattern].source if constraints[:pattern]
63
+
64
+ if constraints[:format]
65
+ format_map = {
66
+ email: "email",
67
+ uri: "uri",
68
+ uuid: "uuid"
69
+ }
70
+ schema["format"] = format_map[constraints[:format]] if format_map[constraints[:format]]
71
+ end
72
+ end
73
+
74
+ def self.add_numeric_constraints(schema, constraints)
75
+ schema["minimum"] = constraints[:ge] if constraints[:ge]
76
+ schema["exclusiveMinimum"] = constraints[:gt] if constraints[:gt]
77
+ schema["maximum"] = constraints[:le] if constraints[:le]
78
+ schema["exclusiveMaximum"] = constraints[:lt] if constraints[:lt]
79
+ schema["multipleOf"] = constraints[:multiple_of] if constraints[:multiple_of]
80
+ end
81
+
82
+ def self.add_array_constraints(schema, constraints, defs_registry: nil, by_alias: false)
83
+ schema["minItems"] = constraints[:min_items] if constraints[:min_items]
84
+ schema["maxItems"] = constraints[:max_items] if constraints[:max_items]
85
+ schema["uniqueItems"] = constraints[:unique_items] if constraints[:unique_items]
86
+ if constraints[:element_type]
87
+ schema["items"] =
88
+ to_schema(constraints[:element_type], defs_registry: defs_registry, by_alias: by_alias)
89
+ end
90
+ end
91
+
92
+ def self.add_hash_constraints(schema, constraints)
93
+ schema["minProperties"] = constraints[:min_properties] if constraints[:min_properties]
94
+ schema["maxProperties"] = constraints[:max_properties] if constraints[:max_properties]
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbdantic
4
+ module Serialization
5
+ # Handles model_dump with various options
6
+ class Dumper
7
+ def self.dump(model, **options)
8
+ new(model, **options).dump
9
+ end
10
+
11
+ def initialize(model, mode: nil, include: nil, exclude: nil,
12
+ exclude_unset: false, exclude_defaults: false, by_alias: false)
13
+ @model = model
14
+ @mode = mode
15
+ @include = include
16
+ @exclude = exclude
17
+ @exclude_unset = exclude_unset
18
+ @exclude_defaults = exclude_defaults
19
+ @by_alias = by_alias
20
+ @set_fields = Array(model.instance_variable_get("@__fields_set__"))
21
+ end
22
+
23
+ def dump
24
+ result = {}
25
+
26
+ @model.class.fields.each do |name, field_info|
27
+ next if should_exclude?(name, field_info)
28
+
29
+ # Use alias if requested and present
30
+ output_name = (@by_alias && field_info.alias_name) ? field_info.alias_name.to_sym : name.to_sym
31
+
32
+ result[output_name] = serialize_item(
33
+ @model.instance_variable_get("@#{name}"),
34
+ **nested_dump_options(name, field_info)
35
+ )
36
+ end
37
+
38
+ extra_fields.each do |name|
39
+ next if should_exclude_extra?(name)
40
+ result[name.to_sym] = serialize_item(@model.instance_variable_get("@#{name}"))
41
+ end
42
+
43
+ result
44
+ end
45
+
46
+ private
47
+
48
+ def extra_fields
49
+ Array(@model.instance_variable_get("@__extra_fields__"))
50
+ end
51
+
52
+ def should_exclude?(name, field_info)
53
+ return true if filter_matches?(@exclude, name, field_info)
54
+ return true if @include && !filter_matches?(@include, name, field_info)
55
+ return true if @exclude_unset && !@set_fields.include?(name)
56
+
57
+ if @exclude_defaults && field_info.has_default? && field_info.default_factory.nil?
58
+ current_val = @model.instance_variable_get("@#{name}")
59
+ return true if current_val == field_info.default
60
+ end
61
+
62
+ false
63
+ end
64
+
65
+ def should_exclude_extra?(name)
66
+ return true if extra_filter_matches?(@exclude, name)
67
+ return true if @include && !extra_filter_matches?(@include, name)
68
+ return true if @exclude_unset && !@set_fields.include?(name)
69
+ false
70
+ end
71
+
72
+ def nested_dump_options(field_name, field_info)
73
+ {
74
+ mode: @mode,
75
+ exclude_defaults: @exclude_defaults,
76
+ exclude_unset: @exclude_unset,
77
+ by_alias: @by_alias,
78
+ include: nested_filter_for(@include, field_name, field_info),
79
+ exclude: nested_filter_for(@exclude, field_name, field_info)
80
+ }
81
+ end
82
+
83
+ def nested_filter_for(filter, field_name, field_info = nil)
84
+ return unless filter.is_a?(Hash)
85
+
86
+ filter_keys_for(field_name, field_info).each do |key|
87
+ return filter[key] if filter.key?(key)
88
+ end
89
+
90
+ nil
91
+ end
92
+
93
+ def serialize_item(item, **options)
94
+ case item
95
+ when Rbdantic::BaseModel then Dumper.dump(item, **options)
96
+ when Array then item.map { |v| serialize_item(v, **options) }
97
+ when Hash then item.transform_values { |v| serialize_item(v, **options) }
98
+ else item
99
+ end
100
+ end
101
+
102
+ def filter_matches?(filter, field_name, field_info)
103
+ return false unless filter
104
+
105
+ if filter.is_a?(Hash)
106
+ filter_keys_for(field_name, field_info).any? { |key| filter.key?(key) }
107
+ else
108
+ filter_keys_for(field_name, field_info).any? { |key| filter.include?(key) }
109
+ end
110
+ end
111
+
112
+ def extra_filter_matches?(filter, field_name)
113
+ return false unless filter
114
+
115
+ keys = filter_keys_for(field_name)
116
+ if filter.is_a?(Hash)
117
+ keys.any? { |key| filter.key?(key) }
118
+ else
119
+ keys.any? { |key| filter.include?(key) }
120
+ end
121
+ end
122
+
123
+ def filter_keys_for(field_name, field_info = nil)
124
+ keys = [field_name, field_name.to_s]
125
+ if @by_alias && field_info&.alias_name
126
+ keys << field_info.alias_name.to_sym
127
+ keys << field_info.alias_name.to_s
128
+ end
129
+ keys.uniq
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rbdantic
6
+ module Serialization
7
+ # JSON serialization and deserialization
8
+ class JsonSerializer
9
+ # Serialize model to JSON string
10
+ # @param model [BaseModel] the model instance
11
+ # @param indent [Integer, nil] JSON indentation
12
+ # @return [String] JSON string
13
+ def self.dump(model, indent: nil, **options)
14
+ data = Dumper.dump(model, mode: :json, **options)
15
+
16
+ if indent
17
+ JSON.pretty_generate(data, indent: normalize_indent(indent))
18
+ else
19
+ JSON.generate(data)
20
+ end
21
+ end
22
+
23
+ # Parse JSON string into model
24
+ # @param json_string [String] JSON data
25
+ # @param model_class [Class] target model class
26
+ # @return [BaseModel] model instance
27
+ def self.load(json_string, model_class)
28
+ data = JSON.parse(json_string, symbolize_names: true)
29
+ model_class.new(data)
30
+ end
31
+
32
+ # Parse JSON string into model (raises on error)
33
+ # @param json_string [String] JSON data
34
+ # @param model_class [Class] target model class
35
+ # @return [BaseModel] model instance
36
+ def self.parse!(json_string, model_class)
37
+ load(json_string, model_class)
38
+ end
39
+
40
+ # Parse JSON string safely, returns nil on error
41
+ # @param json_string [String] JSON data
42
+ # @param model_class [Class] target model class
43
+ # @return [BaseModel, nil] model instance or nil
44
+ def self.parse(json_string, model_class)
45
+ begin
46
+ load(json_string, model_class)
47
+ rescue JSON::ParserError, ValidationError
48
+ nil
49
+ end
50
+ end
51
+
52
+ def self.normalize_indent(indent)
53
+ return indent if indent.is_a?(String)
54
+
55
+ " " * indent.to_i
56
+ end
57
+ private_class_method :normalize_indent
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbdantic
4
+ module Validators
5
+ # Decorator for field-level validators
6
+ # Usage:
7
+ # field_validator :age, mode: :after do |value|
8
+ # raise "must be at least 18" if value < 18
9
+ # end
10
+ class FieldValidator
11
+ MODES = %i[before after plain wrap].freeze
12
+
13
+ attr_reader :field_name, :mode, :validator_proc
14
+
15
+ def initialize(field_name, mode: :after, &block)
16
+ @field_name = field_name
17
+ @mode = validate_mode!(mode)
18
+ @validator_proc = block
19
+ end
20
+
21
+ # Execute the validator
22
+ # @param value the field value
23
+ # @param context [ValidatorContext] validation context
24
+ # @return [Array<ErrorDetail>] errors from validation
25
+ def call(value, context)
26
+ errors, = apply(value, context)
27
+ errors
28
+ end
29
+
30
+ # Execute the validator and optionally transform the value
31
+ # @param value the field value
32
+ # @param context [ValidatorContext] validation context
33
+ # @param handler [Proc, nil] callable for inner validation (wrap mode only)
34
+ # @return [Array] tuple of [errors, transformed_value]
35
+ def apply(value, context, handler = nil, &handler_block)
36
+ errors = []
37
+ transformed_value = value
38
+
39
+ begin
40
+ handler ||= handler_block
41
+
42
+ # For wrap mode, pass handler if available (Issue 4 fix)
43
+ if @mode == :wrap && handler
44
+ result = @validator_proc.call(value, context, handler)
45
+ else
46
+ result = @validator_proc.call(value, context)
47
+ end
48
+
49
+ # Returning false signals validation failure.
50
+ # Any other non-nil/non-true return value is treated as a transformed value.
51
+ if result == false
52
+ errors << ErrorDetail.new(
53
+ type: :validation_failed,
54
+ loc: [@field_name],
55
+ msg: "Custom validation failed",
56
+ input: value
57
+ )
58
+ elsif !result.nil? && result != true
59
+ transformed_value = result
60
+ end
61
+ rescue StandardError => e
62
+ errors << ErrorDetail.new(
63
+ type: :validation_failed,
64
+ loc: [@field_name],
65
+ msg: e.message,
66
+ input: value
67
+ )
68
+ end
69
+
70
+ [errors, transformed_value]
71
+ end
72
+
73
+ private
74
+
75
+ def validate_mode!(mode)
76
+ unless MODES.include?(mode)
77
+ raise ArgumentError, "mode must be one of #{MODES.join(", ")}"
78
+ end
79
+ mode
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbdantic
4
+ module Validators
5
+ # Decorator for model-level validators
6
+ # Usage:
7
+ # model_validator mode: :before do |data|
8
+ # data[:email] = data[:email]&.downcase
9
+ # data
10
+ # end
11
+ class ModelValidator
12
+ MODES = %i[before after].freeze
13
+
14
+ attr_reader :mode, :validator_proc
15
+
16
+ def initialize(mode: :after, &block)
17
+ @mode = validate_mode!(mode)
18
+ @validator_proc = block
19
+ end
20
+
21
+ # Execute the :before validator, which receives and must return a Hash.
22
+ # Only valid to call when mode == :before.
23
+ # @param data [Hash] the input data
24
+ # @return [Hash] modified data
25
+ def call(data)
26
+ @validator_proc.call(data)
27
+ end
28
+
29
+ # Run validation and collect errors
30
+ # @param model_instance the model instance (for after mode)
31
+ # @return [Array<ErrorDetail>] errors from validation
32
+ def validate(model_instance)
33
+ errors = []
34
+
35
+ begin
36
+ @validator_proc.call(model_instance)
37
+ rescue StandardError => e
38
+ errors << ErrorDetail.new(
39
+ type: :model_validation_failed,
40
+ loc: [],
41
+ msg: e.message,
42
+ input: nil
43
+ )
44
+ end
45
+
46
+ errors
47
+ end
48
+
49
+ private
50
+
51
+ def validate_mode!(mode)
52
+ unless MODES.include?(mode)
53
+ raise ArgumentError, "mode must be one of #{MODES.join(", ")}"
54
+ end
55
+ mode
56
+ end
57
+ end
58
+ end
59
+ end