castkit 0.1.2 → 0.3.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +195 -219
  3. data/CHANGELOG.md +42 -0
  4. data/README.md +744 -83
  5. data/castkit.gemspec +1 -0
  6. data/lib/castkit/attribute.rb +6 -24
  7. data/lib/castkit/castkit.rb +61 -10
  8. data/lib/castkit/cli/generate.rb +98 -0
  9. data/lib/castkit/cli/list.rb +200 -0
  10. data/lib/castkit/cli/main.rb +43 -0
  11. data/lib/castkit/cli.rb +24 -0
  12. data/lib/castkit/configuration.rb +116 -46
  13. data/lib/castkit/contract/base.rb +168 -0
  14. data/lib/castkit/contract/data_object.rb +62 -0
  15. data/lib/castkit/contract/result.rb +74 -0
  16. data/lib/castkit/contract/validator.rb +248 -0
  17. data/lib/castkit/contract.rb +67 -0
  18. data/lib/castkit/{data_object_extensions → core}/attribute_types.rb +21 -7
  19. data/lib/castkit/{data_object_extensions → core}/attributes.rb +8 -3
  20. data/lib/castkit/core/config.rb +74 -0
  21. data/lib/castkit/core/registerable.rb +59 -0
  22. data/lib/castkit/data_object.rb +56 -67
  23. data/lib/castkit/error.rb +15 -3
  24. data/lib/castkit/ext/attribute/access.rb +67 -0
  25. data/lib/castkit/ext/attribute/error_handling.rb +63 -0
  26. data/lib/castkit/ext/attribute/options.rb +142 -0
  27. data/lib/castkit/ext/attribute/validation.rb +85 -0
  28. data/lib/castkit/ext/data_object/contract.rb +96 -0
  29. data/lib/castkit/ext/data_object/deserialization.rb +167 -0
  30. data/lib/castkit/ext/data_object/plugins.rb +86 -0
  31. data/lib/castkit/ext/data_object/serialization.rb +61 -0
  32. data/lib/castkit/inflector.rb +47 -0
  33. data/lib/castkit/plugins.rb +82 -0
  34. data/lib/castkit/serializers/base.rb +94 -0
  35. data/lib/castkit/serializers/default_serializer.rb +156 -0
  36. data/lib/castkit/types/base.rb +122 -0
  37. data/lib/castkit/types/boolean.rb +47 -0
  38. data/lib/castkit/types/collection.rb +35 -0
  39. data/lib/castkit/types/date.rb +34 -0
  40. data/lib/castkit/types/date_time.rb +34 -0
  41. data/lib/castkit/types/float.rb +46 -0
  42. data/lib/castkit/types/integer.rb +46 -0
  43. data/lib/castkit/types/string.rb +44 -0
  44. data/lib/castkit/types.rb +15 -0
  45. data/lib/castkit/validators/base.rb +59 -0
  46. data/lib/castkit/validators/boolean_validator.rb +39 -0
  47. data/lib/castkit/validators/collection_validator.rb +29 -0
  48. data/lib/castkit/validators/float_validator.rb +31 -0
  49. data/lib/castkit/validators/integer_validator.rb +31 -0
  50. data/lib/castkit/validators/numeric_validator.rb +2 -2
  51. data/lib/castkit/validators/string_validator.rb +3 -4
  52. data/lib/castkit/version.rb +1 -1
  53. data/lib/castkit.rb +2 -0
  54. data/lib/generators/base.rb +97 -0
  55. data/lib/generators/contract.rb +68 -0
  56. data/lib/generators/data_object.rb +48 -0
  57. data/lib/generators/plugin.rb +25 -0
  58. data/lib/generators/serializer.rb +28 -0
  59. data/lib/generators/templates/contract.rb.tt +24 -0
  60. data/lib/generators/templates/contract_spec.rb.tt +76 -0
  61. data/lib/generators/templates/data_object.rb.tt +15 -0
  62. data/lib/generators/templates/data_object_spec.rb.tt +36 -0
  63. data/lib/generators/templates/plugin.rb.tt +37 -0
  64. data/lib/generators/templates/plugin_spec.rb.tt +18 -0
  65. data/lib/generators/templates/serializer.rb.tt +24 -0
  66. data/lib/generators/templates/serializer_spec.rb.tt +14 -0
  67. data/lib/generators/templates/type.rb.tt +55 -0
  68. data/lib/generators/templates/type_spec.rb.tt +42 -0
  69. data/lib/generators/templates/validator.rb.tt +26 -0
  70. data/lib/generators/templates/validator_spec.rb.tt +23 -0
  71. data/lib/generators/type.rb +29 -0
  72. data/lib/generators/validator.rb +41 -0
  73. metadata +74 -15
  74. data/lib/castkit/attribute_extensions/access.rb +0 -65
  75. data/lib/castkit/attribute_extensions/casting.rb +0 -147
  76. data/lib/castkit/attribute_extensions/error_handling.rb +0 -83
  77. data/lib/castkit/attribute_extensions/options.rb +0 -131
  78. data/lib/castkit/attribute_extensions/serialization.rb +0 -89
  79. data/lib/castkit/attribute_extensions/validation.rb +0 -72
  80. data/lib/castkit/data_object_extensions/config.rb +0 -113
  81. data/lib/castkit/data_object_extensions/deserialization.rb +0 -110
  82. data/lib/castkit/default_serializer.rb +0 -123
  83. data/lib/castkit/serializer.rb +0 -92
  84. data/lib/castkit/validators.rb +0 -4
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module Ext
5
+ module DataObject
6
+ # Provides plugin support for DataObject classes.
7
+ #
8
+ # This module allows a Castkit::DataObject to explicitly declare supported plugins,
9
+ # and ensures all default plugins are enabled on subclassing.
10
+ #
11
+ # Plugins should be defined under `Castkit::Plugins::<Name>` and can be registered
12
+ # globally via `Castkit.configure { |c| c.register_plugin(:name, MyPlugin) }`.
13
+ #
14
+ # Example:
15
+ # class MyDto < Castkit::DataObject
16
+ # enable_plugins :oj, :msgpack
17
+ # disable_plugins :yaml
18
+ # end
19
+ #
20
+ module Plugins
21
+ # Returns the set of plugins explicitly enabled on the class.
22
+ #
23
+ # @return [Set<Symbol>] enabled plugin names
24
+ def enabled_plugins
25
+ @enabled_plugins ||= Set.new
26
+ end
27
+
28
+ # Returns the set of default plugins explicitly disabled on the class.
29
+ #
30
+ # @return [Set<Symbol>] disabled plugin names
31
+ def disabled_plugins
32
+ @disabled_plugins ||= Set.new
33
+ end
34
+
35
+ # Enables one or more plugins on the calling class.
36
+ #
37
+ # @param plugins [Array<Symbol>] plugin identifiers (e.g., :oj, :yaml)
38
+ # @return [void]
39
+ def enable_plugins(*plugins)
40
+ return if plugins.empty?
41
+
42
+ @enabled_plugins ||= Set.new
43
+ @enabled_plugins.merge(plugins)
44
+
45
+ Castkit::Plugins.activate(self, *plugins)
46
+ end
47
+
48
+ # Disables one or more default plugins on the calling class.
49
+ #
50
+ # @example
51
+ # Castkit.configure do |config|
52
+ # config.default_plugins [:oj, :activerecord]
53
+ # end
54
+ #
55
+ # class UserDto < Castkit::DataObject
56
+ # disable_plugin :activerecord
57
+ # end
58
+ #
59
+ # @param plugins [Array<Symbol>] plugin identifiers (e.g., :oj, :yaml)
60
+ # @return [void]
61
+ def disable_plugins(*plugins)
62
+ return if plugins.empty?
63
+
64
+ @disabled_plugins ||= Set.new
65
+ @disabled_plugins.merge(plugins)
66
+ end
67
+
68
+ # Hook that is called when a DataObject subclass is created.
69
+ #
70
+ # Automatically applies `Castkit.configuration.default_plugins`
71
+ # to the subclass.
72
+ #
73
+ # @param subclass [Class] the inheriting subclass
74
+ # @return [void]
75
+ def inherited(subclass)
76
+ super
77
+
78
+ disabled = instance_variable_get(:@disabled_plugins) || Set.new
79
+ plugins = Castkit.configuration.default_plugins - disabled.to_a
80
+
81
+ subclass.enable_plugins(*plugins)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ 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
+ underscore(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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ # Internal registry for Castkit plugin modules.
5
+ #
6
+ # This module supports registering, activating, and looking up Castkit plugins.
7
+ # Plugins are typically included in Castkit::DataObject subclasses via `.enable_plugins`.
8
+ #
9
+ # Plugins can be defined under `Castkit::Plugins::<Name>` or manually registered
10
+ # through the configuration API.
11
+ #
12
+ # @example Registering a custom plugin
13
+ # Castkit.configure do |config|
14
+ # config.register_plugin(:custom, MyCustomPlugin)
15
+ # end
16
+ #
17
+ # @example Enabling plugins on a DataObject
18
+ # class MyDto < Castkit::DataObject
19
+ # enable_plugins :custom, :oj
20
+ # end
21
+ module Plugins
22
+ @registered_plugins = {}
23
+
24
+ class << self
25
+ # Activates one or more plugins on the given class.
26
+ #
27
+ # Each plugin module is included into the class. If the module responds to `setup!`,
28
+ # it will be called with the class as the argument.
29
+ #
30
+ # @param klass [Class] the target class (usually a Castkit::DataObject subclass)
31
+ # @param names [Array<Symbol>] plugin names (e.g., :oj, :yaml)
32
+ # @return [void]
33
+ def activate(klass, *names)
34
+ names.each do |name|
35
+ plugin = lookup!(name)
36
+ klass.include(plugin) if plugin
37
+ plugin.setup!(klass) if plugin.respond_to?(:setup!)
38
+ end
39
+ end
40
+
41
+ # (Placeholder) Deactivates plugins by name.
42
+ #
43
+ # Currently not implemented, included for future API completeness.
44
+ #
45
+ # @param _klass [Class] the class to deactivate plugins on
46
+ # @param names [Array<Symbol>] plugin names to deactivate
47
+ # @return [void]
48
+ def deactivate(_klass, *names)
49
+ @deactivate_plugins = names
50
+ end
51
+
52
+ # Looks up a plugin module by name.
53
+ #
54
+ # This will first check the internal registry, then fall back to
55
+ # resolving a constant under `Castkit::Plugins::<Name>`.
56
+ #
57
+ # @param name [Symbol, String] the plugin name (e.g., :oj)
58
+ # @return [Module] the plugin module
59
+ # @raise [Castkit::Error] if no plugin is found
60
+ def lookup!(name)
61
+ @registered_plugins[name.to_sym] ||
62
+ const_get(Castkit::Inflector.pascalize(name.to_s), false)
63
+ rescue NameError
64
+ raise Castkit::Error,
65
+ "Castkit plugin `#{name}` could not be found. Make sure it is " \
66
+ "defined under Castkit::Plugins or registered using " \
67
+ "`Castkit.configure { |c| c.register_plugin(:#{name}, MyPlugin) }`."
68
+ end
69
+
70
+ # Registers a plugin module under a custom name.
71
+ #
72
+ # This allows developers to register modules not defined under Castkit::Plugins.
73
+ #
74
+ # @param name [Symbol] the plugin name (e.g., :custom_plugin)
75
+ # @param plugin [Module] the plugin module to register
76
+ # @return [void]
77
+ def register(name, plugin)
78
+ @registered_plugins[name] = plugin
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Castkit
6
+ module Serializers
7
+ # Abstract base class for defining custom serializers for Castkit::DataObject instances.
8
+ #
9
+ # Handles circular reference detection and provides a consistent `call` API.
10
+ #
11
+ # Subclasses must implement an instance method `#call` that returns a hash-like representation.
12
+ #
13
+ # @example Usage
14
+ # class CustomSerializer < Castkit::Serializers::Base
15
+ # private
16
+ #
17
+ # def call
18
+ # { type: object.class.name, data: object.to_h }
19
+ # end
20
+ # end
21
+ #
22
+ # CustomSerializer.call(user_dto)
23
+ class Base
24
+ class << self
25
+ # Entrypoint for serializing an object.
26
+ #
27
+ # @param object [Castkit::DataObject] the object to serialize
28
+ # @param visited [Set, nil] used to track visited object IDs
29
+ # @return [Object] result of custom serialization
30
+ def call(object, visited: nil)
31
+ new(object, visited: visited).send(:serialize)
32
+ end
33
+ end
34
+
35
+ # @return [Castkit::DataObject] the object being serialized
36
+ attr_reader :object
37
+
38
+ protected
39
+
40
+ # Fallback to the default serializer.
41
+ #
42
+ # @return [Hash]
43
+ def serialize_with_default
44
+ Castkit::Serializers::DefaultSerializer.call(object, visited: visited)
45
+ end
46
+
47
+ private
48
+
49
+ # @return [Set<Integer>] a set of visited object IDs to detect circular references
50
+ attr_reader :visited
51
+
52
+ # Initializes the serializer instance.
53
+ #
54
+ # @param object [Castkit::DataObject]
55
+ # @param visited [Set, nil]
56
+ def initialize(object, visited: nil)
57
+ @object = object
58
+ @visited = visited || Set.new
59
+ end
60
+
61
+ # Subclasses must override this method to implement serialization logic.
62
+ #
63
+ # @raise [NotImplementedError]
64
+ # @return [Object]
65
+ def call
66
+ raise NotImplementedError, "#{self.class.name} must implement `#call`"
67
+ end
68
+
69
+ # Wraps the actual serialization logic with circular reference detection.
70
+ #
71
+ # @return [Object]
72
+ # @raise [Castkit::SerializationError] if a circular reference is detected
73
+ def serialize
74
+ check_circular_reference!
75
+ visited << object.object_id
76
+
77
+ result = call
78
+ visited.delete(object.object_id)
79
+
80
+ result
81
+ end
82
+
83
+ # Raises if the object has already been visited (circular reference).
84
+ #
85
+ # @raise [Castkit::SerializationError]
86
+ # @return [void]
87
+ def check_circular_reference!
88
+ return unless visited.include?(object.object_id)
89
+
90
+ raise Castkit::SerializationError, "Circular reference detected for #{object.class}"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Castkit
6
+ module Serializers
7
+ # Default serializer for Castkit::DataObject instances.
8
+ #
9
+ # Serializes attributes into a plain Ruby hash, applying access rules, nil/blank filtering,
10
+ # and nested structure handling. The output format supports JSON-compatible structures
11
+ # and respects the class-level serialization configuration.
12
+ class DefaultSerializer < Castkit::Serializers::Base
13
+ # @return [Hash{Symbol => Castkit::Attribute}] the attributes to serialize
14
+ attr_reader :attributes
15
+
16
+ # @return [Hash{Symbol => Object}] unrecognized attributes captured during deserialization
17
+ attr_reader :unknown_attributes
18
+
19
+ # @return [Hash] serialization config flags like :root, :ignore_nil, :allow_unknown
20
+ attr_reader :options
21
+
22
+ # Serializes the object to a hash.
23
+ #
24
+ # Includes unknown attributes if configured, and wraps in a root key if defined.
25
+ #
26
+ # @return [Hash] the fully serialized result
27
+ def call
28
+ result = serialize_attributes
29
+ result.merge!(unknown_attributes) if options[:allow_unknown]
30
+
31
+ options[:root] ? { options[:root].to_sym => result } : result
32
+ end
33
+
34
+ private
35
+
36
+ # Initializes the serializer.
37
+ #
38
+ # @param object [Castkit::DataObject] the object to serialize
39
+ # @param visited [Set, nil] tracks circular references
40
+ def initialize(object, visited: nil)
41
+ super
42
+
43
+ @skip_flag = "__castkit_#{object.object_id}"
44
+ @attributes = object.class.attributes.freeze
45
+ @unknown_attributes = object.unknown_attributes.freeze
46
+ @options = {
47
+ root: object.class.root,
48
+ ignore_nil: object.class.ignore_nil || false,
49
+ allow_unknown: object.class.allow_unknown || false
50
+ }
51
+ end
52
+
53
+ # Serializes all defined attributes.
54
+ #
55
+ # @return [Hash] serialized attribute key-value pairs
56
+ def serialize_attributes
57
+ attributes.values.each_with_object({}) do |attribute, hash|
58
+ next if attribute.skip_serialization?
59
+
60
+ serialized_value = serialize_attribute(attribute)
61
+ next if serialized_value == @skip_flag
62
+
63
+ assign_attribute_key!(attribute, serialized_value, hash)
64
+ end
65
+ end
66
+
67
+ # Serializes a single attribute.
68
+ #
69
+ # @param attribute [Castkit::Attribute]
70
+ # @return [Object] the serialized value or skip flag
71
+ def serialize_attribute(attribute)
72
+ value = object.public_send(attribute.field)
73
+ return @skip_flag if skip_nil?(attribute, value)
74
+
75
+ serialized_value = process_attribute(attribute, value)
76
+ return @skip_flag if skip_blank?(attribute, serialized_value)
77
+
78
+ serialized_value
79
+ end
80
+
81
+ # Delegates serialization based on type.
82
+ #
83
+ # @param attribute [Castkit::Attribute]
84
+ # @param value [Object]
85
+ # @return [Object]
86
+ def process_attribute(attribute, value)
87
+ if attribute.dataobject?
88
+ serialize_dataobject(attribute, value)
89
+ elsif attribute.dataobject_collection?
90
+ Array(value).map { |v| serialize_dataobject(attribute, v) }
91
+ else
92
+ type = Array(attribute.type).first
93
+ Castkit.type_serializer(type).call(value)
94
+ end
95
+ end
96
+
97
+ # Assigns value into nested hash structure based on key path.
98
+ #
99
+ # @param attribute [Castkit::Attribute]
100
+ # @param value [Object]
101
+ # @param hash [Hash]
102
+ # @return [void]
103
+ def assign_attribute_key!(attribute, value, hash)
104
+ key_path = attribute.key_path
105
+ last = key_path.pop
106
+ current = hash
107
+
108
+ key_path.each do |key|
109
+ current[key] ||= {}
110
+ current = current[key]
111
+ end
112
+
113
+ current[last] = value
114
+ end
115
+
116
+ # Whether to skip serialization for nil values.
117
+ #
118
+ # @param attribute [Castkit::Attribute]
119
+ # @param value [Object]
120
+ # @return [Boolean]
121
+ def skip_nil?(attribute, value)
122
+ value.nil? && (attribute.ignore_nil? || options[:ignore_nil])
123
+ end
124
+
125
+ # Whether to skip serialization for blank values.
126
+ #
127
+ # @param attribute [Castkit::Attribute]
128
+ # @param value [Object]
129
+ # @return [Boolean]
130
+ def skip_blank?(attribute, value)
131
+ blank?(value) && (attribute.ignore_blank? || options[:ignore_blank])
132
+ end
133
+
134
+ # True if value is nil or empty.
135
+ #
136
+ # @param value [Object]
137
+ # @return [Boolean]
138
+ def blank?(value)
139
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
140
+ end
141
+
142
+ # Serializes a DataObject using the proper serializer.
143
+ #
144
+ # @param attribute [Castkit::Attribute]
145
+ # @param value [Castkit::DataObject]
146
+ # @return [Object]
147
+ def serialize_dataobject(attribute, value)
148
+ serializer = attribute.options[:serializer]
149
+ serializer ||= value.class.serializer
150
+ serializer ||= Castkit::Serializers::DefaultSerializer
151
+
152
+ serializer.call(value, visited: visited)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module Types
5
+ # Abstract 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 Base
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::Base]
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, context: nil)
115
+ message = "#{context || "value"} must be a #{type}, got #{value}"
116
+ raise Castkit::AttributeError, message if Castkit.configuration.raise_type_errors
117
+
118
+ Castkit.warning "[Castkit] #{message}" if Castkit.configuration.enable_warnings
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../validators/boolean_validator"
5
+
6
+ module Castkit
7
+ module Types
8
+ # Type definition for `:boolean` attributes.
9
+ #
10
+ # Converts strings or numbers into boolean values based on common truthy/falsy indicators.
11
+ #
12
+ # This class is used internally by Castkit when an attribute is defined with:
13
+ # `boolean :is_active`
14
+ class Boolean < Base
15
+ # Deserializes the input into a boolean value.
16
+ #
17
+ # Accepts:
18
+ # - `"true"`, `"1"` (case-insensitive) → `true`
19
+ # - `"false"`, `"0"` (case-insensitive) → `false`
20
+ #
21
+ # @param value [Object]
22
+ # @return [Boolean]
23
+ # @raise [Castkit::TypeError] if the value cannot be coerced to a boolean
24
+ def deserialize(value)
25
+ value
26
+ end
27
+
28
+ # Serializes the boolean value (pass-through).
29
+ #
30
+ # @param value [Boolean]
31
+ # @return [Boolean]
32
+ def serialize(value)
33
+ value
34
+ end
35
+
36
+ # Validates the Boolean value.
37
+ #
38
+ # @param value [Object]
39
+ # @param options [Hash] validation options
40
+ # @param context [Symbol, String] attribute context for error messages
41
+ # @return [void]
42
+ def validate!(value, options: {}, context: {})
43
+ Castkit::Validators::BooleanValidator.call(value, options: options, context: context)
44
+ end
45
+ end
46
+ end
47
+ end