castkit 0.1.2 → 0.2.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +196 -219
  3. data/CHANGELOG.md +42 -0
  4. data/README.md +469 -84
  5. data/lib/castkit/attribute.rb +6 -24
  6. data/lib/castkit/castkit.rb +58 -10
  7. data/lib/castkit/configuration.rb +94 -47
  8. data/lib/castkit/contract/data_object.rb +62 -0
  9. data/lib/castkit/contract/generic.rb +168 -0
  10. data/lib/castkit/contract/result.rb +74 -0
  11. data/lib/castkit/contract/validator.rb +248 -0
  12. data/lib/castkit/contract.rb +67 -0
  13. data/lib/castkit/{data_object_extensions → core}/attribute_types.rb +21 -7
  14. data/lib/castkit/{data_object_extensions → core}/attributes.rb +8 -3
  15. data/lib/castkit/core/config.rb +74 -0
  16. data/lib/castkit/core/registerable.rb +59 -0
  17. data/lib/castkit/data_object.rb +45 -60
  18. data/lib/castkit/default_serializer.rb +85 -54
  19. data/lib/castkit/error.rb +15 -3
  20. data/lib/castkit/ext/attribute/access.rb +67 -0
  21. data/lib/castkit/ext/attribute/error_handling.rb +63 -0
  22. data/lib/castkit/ext/attribute/options.rb +142 -0
  23. data/lib/castkit/ext/attribute/validation.rb +85 -0
  24. data/lib/castkit/ext/data_object/contract.rb +96 -0
  25. data/lib/castkit/ext/data_object/deserialization.rb +167 -0
  26. data/lib/castkit/ext/data_object/serialization.rb +61 -0
  27. data/lib/castkit/inflector.rb +47 -0
  28. data/lib/castkit/types/boolean.rb +43 -0
  29. data/lib/castkit/types/collection.rb +24 -0
  30. data/lib/castkit/types/date.rb +34 -0
  31. data/lib/castkit/types/date_time.rb +34 -0
  32. data/lib/castkit/types/float.rb +46 -0
  33. data/lib/castkit/types/generic.rb +123 -0
  34. data/lib/castkit/types/integer.rb +46 -0
  35. data/lib/castkit/types/string.rb +44 -0
  36. data/lib/castkit/types.rb +15 -0
  37. data/lib/castkit/validators/base_validator.rb +39 -0
  38. data/lib/castkit/validators/numeric_validator.rb +2 -2
  39. data/lib/castkit/validators/string_validator.rb +3 -3
  40. data/lib/castkit/version.rb +1 -1
  41. data/lib/castkit.rb +2 -0
  42. metadata +29 -13
  43. data/lib/castkit/attribute_extensions/access.rb +0 -65
  44. data/lib/castkit/attribute_extensions/casting.rb +0 -147
  45. data/lib/castkit/attribute_extensions/error_handling.rb +0 -83
  46. data/lib/castkit/attribute_extensions/options.rb +0 -131
  47. data/lib/castkit/attribute_extensions/serialization.rb +0 -89
  48. data/lib/castkit/attribute_extensions/validation.rb +0 -72
  49. data/lib/castkit/data_object_extensions/config.rb +0 -113
  50. data/lib/castkit/data_object_extensions/deserialization.rb +0 -110
  51. data/lib/castkit/validators.rb +0 -4
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../contract"
4
+
5
+ module Castkit
6
+ module Ext
7
+ module DataObject
8
+ # Extension module that adds contract support to Castkit::DataObject classes.
9
+ #
10
+ # This allows any DataObject to be:
11
+ # - Converted into a contract definition (via `.to_contract`)
12
+ # - Validated against its contract (via `.validate` and `.validate!`)
13
+ # - Reconstructed from a contract class (via `.from_contract`)
14
+ #
15
+ # Example:
16
+ #
17
+ # class UserDto < Castkit::DataObject
18
+ # string :id
19
+ # string :email
20
+ # end
21
+ #
22
+ # contract = UserDto.to_contract
23
+ # result = UserDto.validate(id: "abc")
24
+ #
25
+ # UserDto.from_contract(contract) # => builds an equivalent DataObject class
26
+ #
27
+ # This module is automatically extended by Castkit::DataObject and is not intended
28
+ # to be included manually.
29
+ module Contract
30
+ # Returns the associated Castkit::Contract for this DataObject.
31
+ #
32
+ # Memoizes the contract once it's built. Uses `to_contract` internally.
33
+ #
34
+ # @return [Class<Castkit::Contract::Definition>]
35
+ def contract
36
+ @contract ||= to_contract
37
+ end
38
+
39
+ # Converts the current DataObject into a Castkit::Contract subclass.
40
+ #
41
+ # If the contract has already been defined, returns the existing definition.
42
+ # Otherwise, generates and registers a new contract class under Castkit::Contracts.
43
+ #
44
+ # @param as [String, Symbol, nil] Optional name for the contract.
45
+ # If omitted, inferred from the DataObject name.
46
+ #
47
+ # @return [Class<Castkit::Contract::Definition>] the generated or existing contract
48
+ def to_contract(as: nil)
49
+ Castkit::Contract.from_dataobject(self, as: as)
50
+ end
51
+
52
+ # Constructs a new Castkit::DataObject class from a given contract.
53
+ #
54
+ # This method is the inverse of `.to_contract` and provides a way to
55
+ # generate a DataObject from an existing contract definition.
56
+ #
57
+ # @example
58
+ # UserContract = Castkit::Contract.build(:user) do
59
+ # string :id
60
+ # string :email
61
+ # end
62
+ #
63
+ # UserDto = Castkit::DataObject.from_contract(UserContract)
64
+ # dto = UserDto.new(id: "abc", email: "a@example.com")
65
+ #
66
+ # @param contract [Class<Castkit::Contract::Generic>] the contract to convert
67
+ # @return [Class<Castkit::DataObject>] a new anonymous DataObject class
68
+
69
+ def from_contract(contract)
70
+ Class.new(Castkit::DataObject).tap do |klass|
71
+ contract.attributes.each_value do |attr|
72
+ klass.attribute(attr.field, attr.type, **attr.options)
73
+ end
74
+ end
75
+ end
76
+
77
+ # Validates input data using the contract associated with this DataObject.
78
+ #
79
+ # @param data [Hash] The input to validate
80
+ # @return [Castkit::Contract::Result] the result of validation
81
+ def validate(data)
82
+ contract.validate(data)
83
+ end
84
+
85
+ # Validates input data and raises if validation fails.
86
+ #
87
+ # @param data [Hash] The input to validate
88
+ # @raise [Castkit::ContractError] if validation fails
89
+ # @return [void]
90
+ def validate!(data)
91
+ contract.validate!(data)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module Ext
5
+ module DataObject
6
+ # Adds deserialization support for Castkit::DataObject instances.
7
+ #
8
+ # Handles attribute loading, alias resolution, default fallback, nested DataObject casting,
9
+ # unwrapped field extraction, and optional attribute enforcement.
10
+ module Deserialization
11
+ # Hooks in class methods like `.from_hash` when included.
12
+ #
13
+ # @param base [Class] the class including this module
14
+ def self.included(base)
15
+ base.extend(ClassMethods)
16
+ end
17
+
18
+ # Class-level deserialization helpers for Castkit::DataObject.
19
+ module ClassMethods
20
+ # Builds a new instance from a hash, symbolizing keys as needed.
21
+ #
22
+ # @param hash [Hash] input data
23
+ # @return [Castkit::DataObject] deserialized instance
24
+ def from_hash(hash)
25
+ hash = hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
26
+ new(hash)
27
+ end
28
+
29
+ # @!method from_h(hash)
30
+ # Alias for {.from_hash}
31
+ alias from_h from_hash
32
+
33
+ # @!method deserialize(hash)
34
+ # Alias for {.from_hash}
35
+ alias deserialize from_hash
36
+ end
37
+
38
+ private
39
+
40
+ # Loads and assigns all attributes from input hash.
41
+ #
42
+ # @param input [Hash] the input data
43
+ # @return [void]
44
+ def deserialize_attributes!(input)
45
+ self.class.attributes.each_value do |attribute|
46
+ next if attribute.skip_deserialization?
47
+
48
+ value = resolve_input_value(input, attribute)
49
+ next if value.nil? && attribute.optional?
50
+
51
+ value = deserialize_attribute_value!(attribute, value)
52
+ instance_variable_set("@#{attribute.field}", value)
53
+ end
54
+ end
55
+
56
+ # Deserializes an attribute's value according to its type.
57
+ #
58
+ # @param attribute [Castkit::Attribute]
59
+ # @param value [Object]
60
+ # @return [Object]
61
+ def deserialize_attribute_value!(attribute, value)
62
+ value = attribute.default if value.nil?
63
+ raise Castkit::AttributeError, "#{attribute.field} cannot be nil" if required?(attribute, value)
64
+
65
+ if attribute.dataobject?
66
+ attribute.type.cast(value)
67
+ elsif attribute.dataobject_collection?
68
+ Array(value).map { |v| attribute.options[:of].cast(v) }
69
+ else
70
+ deserialize_primitive_value!(attribute, value)
71
+ end
72
+ end
73
+
74
+ # Attempts to deserialize a primitive or union-typed value.
75
+ #
76
+ # @param attribute [Castkit::Attribute]
77
+ # @param value [Object]
78
+ # @return [Object]
79
+ # @raise [Castkit::AttributeError] if no type matches
80
+ def deserialize_primitive_value!(attribute, value)
81
+ Array(attribute.type).each do |type|
82
+ return Castkit.type_deserializer(type).call(value)
83
+ rescue Castkit::TypeError, Castkit::AttributeError
84
+ next
85
+ end
86
+
87
+ raise Castkit::AttributeError,
88
+ "#{attribute.field} could not be deserialized into any of #{attribute.type.inspect}"
89
+ end
90
+
91
+ # Checks whether an attribute is required and its value is nil.
92
+ #
93
+ # @param attribute [Castkit::Attribute]
94
+ # @param value [Object]
95
+ # @return [Boolean]
96
+ def required?(attribute, value)
97
+ value.nil? && attribute.required?
98
+ end
99
+
100
+ # Finds the first matching value for an attribute using key and alias paths.
101
+ #
102
+ # @param input [Hash]
103
+ # @param attribute [Castkit::Attribute]
104
+ # @return [Object, nil]
105
+ def resolve_input_value(input, attribute)
106
+ attribute.key_path(with_aliases: true).each do |path|
107
+ value = path.reduce(input) { |memo, key| memo.is_a?(Hash) ? memo[key] : nil }
108
+ return value unless value.nil?
109
+ end
110
+
111
+ nil
112
+ end
113
+
114
+ # Resolves root-wrapped and unwrapped data.
115
+ #
116
+ # @param data [Hash]
117
+ # @return [Hash] transformed input
118
+ def unwrap_root(data)
119
+ root = self.class.root
120
+ data = data[root] if root && data.key?(root)
121
+
122
+ unwrap_prefixed_fields!(data)
123
+ end
124
+
125
+ # Nests prefixed fields under their parent attribute for unwrapped dataobjects.
126
+ #
127
+ # @param data [Hash]
128
+ # @return [Hash] modified input
129
+ def unwrap_prefixed_fields!(data)
130
+ self.class.attributes.each_value do |attribute|
131
+ next unless attribute.unwrapped?
132
+
133
+ unwrapped, keys_to_remove = unwrap_prefixed_values(data, attribute)
134
+ next if unwrapped.empty?
135
+
136
+ data[attribute.field] = unwrapped
137
+ keys_to_remove.each { |k| data.delete(k) }
138
+ end
139
+
140
+ data
141
+ end
142
+
143
+ # Extracts and strips prefixed keys for unwrapped nested attributes.
144
+ #
145
+ # @param data [Hash]
146
+ # @param attribute [Castkit::Attribute]
147
+ # @return [Array<(Hash, Array<Symbol>)] extracted subhash and deleted keys
148
+ def unwrap_prefixed_values(data, attribute)
149
+ prefix = attribute.prefix.to_s
150
+ unwrapped_data = {}
151
+ keys_to_remove = []
152
+
153
+ data.each do |k, v|
154
+ k_str = k.to_s
155
+ next unless k_str.start_with?(prefix)
156
+
157
+ stripped = k_str.sub(prefix, "").to_sym
158
+ unwrapped_data[stripped] = v
159
+ keys_to_remove << k
160
+ end
161
+
162
+ [unwrapped_data, keys_to_remove]
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module Ext
5
+ module DataObject
6
+ # Provides per-class serialization configuration for Castkit::Dataobject, including
7
+ # root key handling and ignore rules.
8
+ module Serialization
9
+ # Automatically extends class-level methods when included.
10
+ #
11
+ # @param base [Class]
12
+ def self.included(base)
13
+ base.extend(ClassMethods)
14
+ end
15
+
16
+ # Class-level configuration methods.
17
+ module ClassMethods
18
+ # Sets or retrieves the root key to wrap the object under during (de)serialization.
19
+ #
20
+ # @param value [String, Symbol, nil] optional root key
21
+ # @return [Symbol, nil]
22
+ def root(value = nil)
23
+ value.nil? ? @root : (@root = value.to_s.strip.to_sym)
24
+ end
25
+
26
+ # Sets or retrieves whether to skip `nil` values in output.
27
+ #
28
+ # @param value [Boolean, nil]
29
+ # @return [Boolean, nil]
30
+ def ignore_nil(value = nil)
31
+ value.nil? ? @ignore_nil : (@ignore_nil = value)
32
+ end
33
+
34
+ # Sets or retrieves whether to skip blank values (`[]`, `{}`, `""`, etc.) in output.
35
+ #
36
+ # Defaults to true unless explicitly set to false.
37
+ #
38
+ # @param value [Boolean, nil]
39
+ # @return [Boolean]
40
+ def ignore_blank(value = nil)
41
+ @ignore_blank = value.nil? || value
42
+ end
43
+ end
44
+
45
+ # Returns the root key for this instance.
46
+ #
47
+ # @return [Symbol]
48
+ def root_key
49
+ self.class.root.to_s.strip.to_sym
50
+ end
51
+
52
+ # Whether a root key is configured for this instance.
53
+ #
54
+ # @return [Boolean]
55
+ def root_key_set?
56
+ !self.class.root.to_s.strip.empty?
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ # Provides string transformation utilities used internally by Castkit
5
+ module Inflector
6
+ class << self
7
+ # Returns the unqualified class name from a namespaced class.
8
+ #
9
+ # @example
10
+ # Castkit::Inflector.class_name(Foo::Bar) # => "Bar"
11
+ #
12
+ # @param klass [Class]
13
+ # @return [String]
14
+ def unqualified_name(klass)
15
+ klass.name.to_s.split("::").last
16
+ end
17
+
18
+ # Converts a snake_case or underscored string into PascalCase.
19
+ #
20
+ # @example
21
+ # Castkit::Inflector.pascalize("user_contract") # => "UserContract"
22
+ # Castkit::Inflector.pascalize(:admin_dto) # => "AdminDto"
23
+ #
24
+ # @param string [String, Symbol] the input to convert
25
+ # @return [String] the PascalCase representation
26
+ def pascalize(string)
27
+ string.to_s.split("_").map(&:capitalize).join
28
+ end
29
+
30
+ # Converts a PascalCase or camelCase string to snake_case.
31
+ #
32
+ # @example
33
+ # Castkit::Inflector.underscore("UserContract") # => "user_contract"
34
+ # Castkit::Inflector.underscore("XMLParser") # => "xml_parser"
35
+ #
36
+ # @param string [String, Symbol]
37
+ # @return [String]
38
+ def underscore(string)
39
+ string
40
+ .to_s
41
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
42
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
43
+ .downcase
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "generic"
4
+
5
+ module Castkit
6
+ module Types
7
+ # Type definition for `:boolean` attributes.
8
+ #
9
+ # Converts strings or numbers into boolean values based on common truthy/falsy indicators.
10
+ #
11
+ # This class is used internally by Castkit when an attribute is defined with:
12
+ # `boolean :is_active`
13
+ class Boolean < Generic
14
+ # Deserializes the input into a boolean value.
15
+ #
16
+ # Accepts:
17
+ # - `"true"`, `"1"` (case-insensitive) → `true`
18
+ # - `"false"`, `"0"` (case-insensitive) → `false`
19
+ #
20
+ # @param value [Object]
21
+ # @return [Boolean]
22
+ # @raise [Castkit::TypeError] if the value cannot be coerced to a boolean
23
+ def deserialize(value)
24
+ case value.to_s.downcase
25
+ when "true", "1"
26
+ true
27
+ when "false", "0"
28
+ false
29
+ else
30
+ type_error!(:boolean, value)
31
+ end
32
+ end
33
+
34
+ # Serializes the boolean value (pass-through).
35
+ #
36
+ # @param value [Boolean]
37
+ # @return [Boolean]
38
+ def serialize(value)
39
+ value
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "generic"
4
+
5
+ module Castkit
6
+ module Types
7
+ # Type definition for `:array` attributes.
8
+ #
9
+ # Wraps any value in an array using `Array(value)` coercion. This ensures consistent array representation
10
+ # even if the input is a single value or nil.
11
+ #
12
+ # This class is used internally by Castkit when an attribute is defined with:
13
+ # `array :tags, of: :string`
14
+ class Collection < Generic
15
+ # Deserializes the value into an array using `Array(value)`.
16
+ #
17
+ # @param value [Object]
18
+ # @return [::Array]
19
+ def deserialize(value)
20
+ Array(value)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "generic"
5
+
6
+ module Castkit
7
+ module Types
8
+ # Type definition for `:date` attributes.
9
+ #
10
+ # Handles deserialization from strings and other input into `Date` objects,
11
+ # and serializes `Date` values into ISO8601 strings.
12
+ #
13
+ # This class is used internally by Castkit when an attribute is defined with:
14
+ # `date :published_on`
15
+ class Date < Generic
16
+ # Deserializes the input value to a `Date` instance.
17
+ #
18
+ # @param value [Object]
19
+ # @return [::Date]
20
+ # @raise [ArgumentError] if parsing fails
21
+ def deserialize(value)
22
+ ::Date.parse(value.to_s)
23
+ end
24
+
25
+ # Serializes a `Date` object to ISO8601 string format.
26
+ #
27
+ # @param value [::Date]
28
+ # @return [String]
29
+ def serialize(value)
30
+ value.iso8601
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "generic"
5
+
6
+ module Castkit
7
+ module Types
8
+ # Type definition for `:datetime` attributes.
9
+ #
10
+ # Handles deserialization from strings and other input into `DateTime` objects,
11
+ # and serializes `DateTime` values into ISO8601 strings.
12
+ #
13
+ # This class is used internally by Castkit when an attribute is defined with:
14
+ # `datetime :published_ad`
15
+ class DateTime < Generic
16
+ # Deserializes the input value to a `DateTime` instance.
17
+ #
18
+ # @param value [Object]
19
+ # @return [::DateTime]
20
+ # @raise [ArgumentError] if parsing fails
21
+ def deserialize(value)
22
+ ::DateTime.parse(value.to_s)
23
+ end
24
+
25
+ # Serializes a `DateTime` object to ISO8601 string format.
26
+ #
27
+ # @param value [::DateTime]
28
+ # @return [String]
29
+ def serialize(value)
30
+ value.iso8601
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "generic"
4
+ require_relative "../validators/numeric_validator"
5
+
6
+ module Castkit
7
+ module Types
8
+ # Type definition for `:integer` attributes.
9
+ #
10
+ # Handles deserialization from raw input (e.g., strings, floats) to Float,
11
+ # applies optional numeric validation rules (e.g., `min`, `max`), and returns
12
+ # the value unchanged during serialization.
13
+ #
14
+ # This class is used internally by Castkit when an attribute is defined with:
15
+ # `integer :count`
16
+ class Float < Generic
17
+ # Deserializes the input value to an Float.
18
+ #
19
+ # @param value [Object]
20
+ # @return [Float]
21
+ def deserialize(value)
22
+ value.to_f
23
+ end
24
+
25
+ # Serializes the Float value.
26
+ #
27
+ # @param value [Float]
28
+ # @return [Float]
29
+ def serialize(value)
30
+ value
31
+ end
32
+
33
+ # Validates the Float value using Castkit's NumericValidator.
34
+ #
35
+ # Supports options like `min:` and `max:`.
36
+ #
37
+ # @param value [Object]
38
+ # @param options [Hash] validation options
39
+ # @param context [Symbol, String] attribute context for error messages
40
+ # @return [void]
41
+ def validate!(value, options: {}, context: {})
42
+ Castkit::Validators::NumericValidator.call(value, options: options, context: context)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module Types
5
+ # Generic base class for type definitions in Castkit.
6
+ #
7
+ # Provides default behavior for (de)serialization, validation, and coercion.
8
+ # All primitive types should subclass this and override methods as needed.
9
+ #
10
+ # The `cast!` method is the primary entry point used by attribute processing
11
+ # to validate and coerce values in a predictable order.
12
+ class Generic
13
+ class << self
14
+ # Coerces and validates a value for use in a Castkit DataObject.
15
+ #
16
+ # When `force_type` is true, the value is deserialized (coerced) first,
17
+ # then validated. This is useful when a value may need to be converted
18
+ # before it can pass validation (e.g. `"123"` → `123`).
19
+ #
20
+ # Otherwise, the raw value is validated before coercion.
21
+ #
22
+ # @param value [Object] the input value
23
+ # @param validator [#call, nil] optional custom validator (default uses `validate!`)
24
+ # @param options [Hash] options passed to `validate!`, e.g., `min`, `max`, `force_type`
25
+ # @param context [Symbol, String, nil] context label for error messages
26
+ # @return [Object] the deserialized and validated value
27
+ def cast!(value, validator: nil, options: {}, context: {})
28
+ instance = new
29
+ validator ||= options.delete(:validator)
30
+ validator ||= default_validator(instance)
31
+
32
+ if options[:force_type]
33
+ deserialized_value = instance.deserialize(value)
34
+ validator.call(deserialized_value, options: options, context: context)
35
+ return deserialized_value
36
+ end
37
+
38
+ validator.call(value, options: options, context: context)
39
+ instance.deserialize(value)
40
+ end
41
+
42
+ # Deserializes the value using the default type behavior.
43
+ #
44
+ # @param value [Object]
45
+ # @return [Object] the coerced value
46
+ def deserialize(value)
47
+ new.deserialize(value)
48
+ end
49
+
50
+ # Serializes the value using the default type behavior.
51
+ #
52
+ # @param value [Object]
53
+ # @return [Object]
54
+ def serialize(value)
55
+ new.serialize(value)
56
+ end
57
+
58
+ # Validates the value using the default validator.
59
+ #
60
+ # @param value [Object] the value to check
61
+ # @param options [Hash] validation rules (e.g., min, max, format)
62
+ # @param context [Symbol, String] label for error reporting
63
+ # @return [void]
64
+ def validate!(value, options: {}, context: {})
65
+ new.validate!(value, options: options, context: context)
66
+ end
67
+
68
+ private
69
+
70
+ # Builds a default validator from the instance itself.
71
+ #
72
+ # @param instance [Castkit::Types::Generic]
73
+ # @return [Proc] a lambda wrapping `#validate!`
74
+ def default_validator(instance)
75
+ lambda do |value, options: {}, context: nil|
76
+ instance.validate!(value, options: options, context: context)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Deserializes the value. Override in subclasses to coerce input (e.g., `"123"` → `123`).
82
+ #
83
+ # @param value [Object]
84
+ # @return [Object]
85
+ def deserialize(value)
86
+ value
87
+ end
88
+
89
+ # Serializes the value. Override in subclasses if the output should be transformed.
90
+ #
91
+ # @param value [Object]
92
+ # @return [Object]
93
+ def serialize(value)
94
+ value
95
+ end
96
+
97
+ # Validates the value. No-op by default.
98
+ #
99
+ # @param value [Object]
100
+ # @param options [Hash]
101
+ # @param context [Symbol, String]
102
+ # @return [void]
103
+ def validate!(value, options: {}, context: {})
104
+ # override in subclasses
105
+ end
106
+
107
+ protected
108
+
109
+ # Emits or raises a type error depending on configuration.
110
+ #
111
+ # @param type [Symbol]
112
+ # @param value [Object, nil]
113
+ # @return [void]
114
+ def type_error!(type, value)
115
+ message = "value must be a #{type}, got #{value.inspect}"
116
+
117
+ raise Castkit::TypeError, message if Castkit.configuration.raise_type_errors
118
+
119
+ Castkit.warning "[Castkit] #{message}" if Castkit.configuration.enable_warnings
120
+ end
121
+ end
122
+ end
123
+ end