castkit 0.2.0 → 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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +118 -119
  3. data/CHANGELOG.md +1 -1
  4. data/README.md +287 -11
  5. data/castkit.gemspec +1 -0
  6. data/lib/castkit/castkit.rb +5 -2
  7. data/lib/castkit/cli/generate.rb +98 -0
  8. data/lib/castkit/cli/list.rb +200 -0
  9. data/lib/castkit/cli/main.rb +43 -0
  10. data/lib/castkit/cli.rb +24 -0
  11. data/lib/castkit/configuration.rb +31 -8
  12. data/lib/castkit/contract/{generic.rb → base.rb} +5 -5
  13. data/lib/castkit/contract/result.rb +2 -2
  14. data/lib/castkit/contract.rb +5 -5
  15. data/lib/castkit/data_object.rb +11 -7
  16. data/lib/castkit/ext/data_object/contract.rb +1 -1
  17. data/lib/castkit/ext/data_object/plugins.rb +86 -0
  18. data/lib/castkit/inflector.rb +1 -1
  19. data/lib/castkit/plugins.rb +82 -0
  20. data/lib/castkit/serializers/base.rb +94 -0
  21. data/lib/castkit/serializers/default_serializer.rb +156 -0
  22. data/lib/castkit/types/{generic.rb → base.rb} +6 -7
  23. data/lib/castkit/types/boolean.rb +14 -10
  24. data/lib/castkit/types/collection.rb +13 -2
  25. data/lib/castkit/types/date.rb +2 -2
  26. data/lib/castkit/types/date_time.rb +2 -2
  27. data/lib/castkit/types/float.rb +5 -5
  28. data/lib/castkit/types/integer.rb +5 -5
  29. data/lib/castkit/types/string.rb +2 -2
  30. data/lib/castkit/types.rb +1 -1
  31. data/lib/castkit/validators/base.rb +59 -0
  32. data/lib/castkit/validators/boolean_validator.rb +39 -0
  33. data/lib/castkit/validators/collection_validator.rb +29 -0
  34. data/lib/castkit/validators/float_validator.rb +31 -0
  35. data/lib/castkit/validators/integer_validator.rb +31 -0
  36. data/lib/castkit/validators/numeric_validator.rb +2 -2
  37. data/lib/castkit/validators/string_validator.rb +3 -4
  38. data/lib/castkit/version.rb +1 -1
  39. data/lib/generators/base.rb +97 -0
  40. data/lib/generators/contract.rb +68 -0
  41. data/lib/generators/data_object.rb +48 -0
  42. data/lib/generators/plugin.rb +25 -0
  43. data/lib/generators/serializer.rb +28 -0
  44. data/lib/generators/templates/contract.rb.tt +24 -0
  45. data/lib/generators/templates/contract_spec.rb.tt +76 -0
  46. data/lib/generators/templates/data_object.rb.tt +15 -0
  47. data/lib/generators/templates/data_object_spec.rb.tt +36 -0
  48. data/lib/generators/templates/plugin.rb.tt +37 -0
  49. data/lib/generators/templates/plugin_spec.rb.tt +18 -0
  50. data/lib/generators/templates/serializer.rb.tt +24 -0
  51. data/lib/generators/templates/serializer_spec.rb.tt +14 -0
  52. data/lib/generators/templates/type.rb.tt +55 -0
  53. data/lib/generators/templates/type_spec.rb.tt +42 -0
  54. data/lib/generators/templates/validator.rb.tt +26 -0
  55. data/lib/generators/templates/validator_spec.rb.tt +23 -0
  56. data/lib/generators/type.rb +29 -0
  57. data/lib/generators/validator.rb +41 -0
  58. metadata +50 -7
  59. data/lib/castkit/default_serializer.rb +0 -154
  60. data/lib/castkit/serializer.rb +0 -92
  61. data/lib/castkit/validators/base_validator.rb +0 -39
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "cli/main"
5
+
6
+ module Castkit
7
+ # Entrypoint for Castkit’s command-line interface.
8
+ #
9
+ # Delegates to the `Castkit::CLI::Main` Thor class, which defines all CLI commands.
10
+ #
11
+ # @example Executing from a binstub
12
+ # Castkit::CLI.start(ARGV)
13
+ #
14
+ module CLI
15
+ # Starts the Castkit CLI.
16
+ #
17
+ # @param args [Array<String>] the command-line arguments
18
+ # @param kwargs [Hash] additional keyword arguments passed to Thor
19
+ # @return [void]
20
+ def self.start(*args, **kwargs)
21
+ Castkit::CLI::Main.start(*args, **kwargs)
22
+ end
23
+ end
24
+ end
@@ -10,14 +10,14 @@ module Castkit
10
10
  class Configuration
11
11
  # Default mapping of primitive type definitions.
12
12
  #
13
- # @return [Hash{Symbol => Castkit::Types::Generic}]
13
+ # @return [Hash{Symbol => Castkit::Types::Base}]
14
14
  DEFAULT_TYPES = {
15
15
  array: Castkit::Types::Collection.new,
16
16
  boolean: Castkit::Types::Boolean.new,
17
17
  date: Castkit::Types::Date.new,
18
18
  datetime: Castkit::Types::DateTime.new,
19
19
  float: Castkit::Types::Float.new,
20
- hash: Castkit::Types::Generic.new,
20
+ hash: Castkit::Types::Base.new,
21
21
  integer: Castkit::Types::Integer.new,
22
22
  string: Castkit::Types::String.new
23
23
  }.freeze
@@ -36,9 +36,15 @@ module Castkit
36
36
  uuid: :string
37
37
  }.freeze
38
38
 
39
- # @return [Hash{Symbol => Castkit::Types::Generic}] registered types
39
+ # @return [Hash{Symbol => Castkit::Types::Base}] registered types
40
40
  attr_reader :types
41
41
 
42
+ # Set default plugins that will be used globally in all Castkit::DataObject subclasses.
43
+ # This is equivalent to calling `enable_plugins` in every class.
44
+ #
45
+ # @return [Array<Symbol>] default plugin names to be applied to all DataObject subclasses
46
+ attr_accessor :default_plugins
47
+
42
48
  # Whether to raise an error if values should be validated before deserializing, e.g. true -> "true"
43
49
  # @return [Boolean]
44
50
  attr_accessor :enforce_typing
@@ -79,6 +85,7 @@ module Castkit
79
85
  @raise_type_errors = true
80
86
  @enable_warnings = true
81
87
  @strict_by_default = true
88
+ @default_plugins = []
82
89
 
83
90
  apply_type_aliases!
84
91
  end
@@ -86,17 +93,17 @@ module Castkit
86
93
  # Registers a new type definition.
87
94
  #
88
95
  # @param type [Symbol] the symbolic type name (e.g., :uuid)
89
- # @param klass [Class<Castkit::Types::Generic>] the class to register
96
+ # @param klass [Class<Castkit::Types::Base>] the class to register
90
97
  # @param override [Boolean] whether to allow overwriting existing registration
91
- # @raise [Castkit::TypeError] if the type class is invalid or not a subclass of Generic
98
+ # @raise [Castkit::TypeError] if the type class is invalid or not a subclass of Castkit::Types::Base
92
99
  # @return [void]
93
100
  def register_type(type, klass, aliases: [], override: false)
94
101
  type = type.to_sym
95
102
  return if types.key?(type) && !override
96
103
 
97
104
  instance = klass.new
98
- unless instance.is_a?(Castkit::Types::Generic)
99
- raise Castkit::TypeError, "Expected subclass of Castkit::Types::Generic for `#{type}`"
105
+ unless instance.is_a?(Castkit::Types::Base)
106
+ raise Castkit::TypeError, "Expected subclass of Castkit::Types::Base for `#{type}`"
100
107
  end
101
108
 
102
109
  types[type] = instance
@@ -107,10 +114,26 @@ module Castkit
107
114
  aliases.each { |alias_type| register_type(alias_type, klass, override: override) }
108
115
  end
109
116
 
117
+ # Register a custom plugin for use with Castkit::DataObject.
118
+ #
119
+ # @example Loading as a default plugin
120
+ # Castkit.configure do |config|
121
+ # config.register_plugin(:custom, CustomPlugin)
122
+ # config.default_plugins [:custom]
123
+ # end
124
+ #
125
+ # @example Loading it directly in a Castkit::DataObject
126
+ # class UserDto < Castkit::DataObject
127
+ # enable_plugins :custom
128
+ # end
129
+ def register_plugin(name, plugin)
130
+ Castkit::Plugins.register(name, plugin)
131
+ end
132
+
110
133
  # Returns the type handler for a given type symbol.
111
134
  #
112
135
  # @param type [Symbol]
113
- # @return [Castkit::Types::Generic]
136
+ # @return [Castkit::Types::Base]
114
137
  # @raise [Castkit::TypeError] if the type is not registered
115
138
  def fetch_type(type)
116
139
  @types.fetch(type.to_sym) do
@@ -14,7 +14,7 @@ module Castkit
14
14
  # ephemeral or reusable contract classes.
15
15
  #
16
16
  # @example Subclassing directly
17
- # class MyContract < Castkit::Contract::Generic
17
+ # class MyContract < Castkit::Contract::Base
18
18
  # string :id
19
19
  # integer :count, required: false
20
20
  # end
@@ -30,7 +30,7 @@ module Castkit
30
30
  # UserContract.validate!(id: "123")
31
31
  #
32
32
  # @see Castkit::Contract.build
33
- class Generic
33
+ class Base
34
34
  extend Castkit::Core::Config
35
35
  extend Castkit::Core::AttributeTypes
36
36
  extend Castkit::Core::Registerable
@@ -69,7 +69,6 @@ module Castkit
69
69
  # @return [Castkit::Contract::Result]
70
70
  def validate(input)
71
71
  validate!(input)
72
- Castkit::Contract::Result.new(definition[:name].to_s, input)
73
72
  rescue Castkit::ContractError => e
74
73
  Castkit::Contract::Result.new(definition[:name].to_s, input, errors: e.errors)
75
74
  end
@@ -81,6 +80,7 @@ module Castkit
81
80
  # @return [void]
82
81
  def validate!(input)
83
82
  Castkit::Contract::Validator.call!(attributes.values, input, **validation_rules)
83
+ Castkit::Contract::Result.new(definition[:name].to_s, input)
84
84
  end
85
85
 
86
86
  # Returns internal contract metadata.
@@ -88,7 +88,7 @@ module Castkit
88
88
  # @return [Hash]
89
89
  def definition
90
90
  @definition ||= {
91
- name: :ephemeral_contract,
91
+ name: :ephemeral,
92
92
  attributes: {}
93
93
  }
94
94
  end
@@ -108,7 +108,7 @@ module Castkit
108
108
  # @param source [Castkit::DataObject, nil]
109
109
  # @param block [Proc, nil]
110
110
  # @return [Hash]
111
- def define(name = :ephemeral_contract, source = nil, validation_rules: {}, &block)
111
+ def define(name = :ephemeral, source = nil, validation_rules: {}, &block)
112
112
  validate_definition!(source, &block)
113
113
 
114
114
  if source
@@ -52,10 +52,10 @@ module Castkit
52
52
  #
53
53
  # @return [String]
54
54
  def to_s
55
- return "[Castkit] Contract validation passed for #{contract.inspect}" if success?
55
+ return "[Castkit] Contract validation passed for #{contract}" if success?
56
56
 
57
57
  parsed_errors = errors.map { |k, v| " #{k}: #{v.inspect}" }.join("\n")
58
- "[Castkit] Contract validation failed for #{contract.inspect}:\n#{parsed_errors}"
58
+ "[Castkit] Contract validation failed for #{contract}:\n#{parsed_errors}"
59
59
  end
60
60
 
61
61
  # @return [Hash{Symbol => Object}] the input validation and error hash
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "contract/generic"
3
+ require_relative "contract/base"
4
4
 
5
5
  module Castkit
6
6
  # Castkit::Contract provides a lightweight mechanism for defining and validating
@@ -31,9 +31,9 @@ module Castkit
31
31
  # @param name [String, Symbol, nil] Optional name for the contract.
32
32
  # @param validation_rules [Hash] Optional validation rules (e.g., `strict: true`).
33
33
  # @yield Optional DSL block to define attributes.
34
- # @return [Class<Castkit::Contract::Generic>]
34
+ # @return [Class<Castkit::Contract::Base>]
35
35
  def build(name = nil, **validation_rules, &block)
36
- klass = Class.new(Castkit::Contract::Generic)
36
+ klass = Class.new(Castkit::Contract::Base)
37
37
  klass.send(:define, name, nil, validation_rules: validation_rules, &block)
38
38
 
39
39
  klass
@@ -52,12 +52,12 @@ module Castkit
52
52
  #
53
53
  # @param source [Class<Castkit::DataObject>] the DataObject to generate the contract from
54
54
  # @param as [String, Symbol, nil] Optional custom name to use for the contract
55
- # @return [Class<Castkit::Contract::Generic>]
55
+ # @return [Class<Castkit::Contract::Base>]
56
56
  def from_dataobject(source, as: nil)
57
57
  name = as || Castkit::Inflector.unqualified_name(source)
58
58
  name = Castkit::Inflector.underscore(name).to_sym
59
59
 
60
- klass = Class.new(Castkit::Contract::Generic)
60
+ klass = Class.new(Castkit::Contract::Base)
61
61
  klass.send(:define, name, source, validation_rules: source.validation_rules)
62
62
 
63
63
  klass
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
  require_relative "error"
5
5
  require_relative "attribute"
6
- require_relative "default_serializer"
6
+ require_relative "serializers/default_serializer"
7
7
  require_relative "contract/validator"
8
8
  require_relative "core/config"
9
9
  require_relative "core/attributes"
@@ -11,6 +11,7 @@ require_relative "core/attribute_types"
11
11
  require_relative "core/registerable"
12
12
  require_relative "ext/data_object/contract"
13
13
  require_relative "ext/data_object/deserialization"
14
+ require_relative "ext/data_object/plugins"
14
15
  require_relative "ext/data_object/serialization"
15
16
 
16
17
  module Castkit
@@ -34,6 +35,7 @@ module Castkit
34
35
  extend Castkit::Core::AttributeTypes
35
36
  extend Castkit::Core::Registerable
36
37
  extend Castkit::Ext::DataObject::Contract
38
+ extend Castkit::Ext::DataObject::Plugins
37
39
 
38
40
  include Castkit::Ext::DataObject::Serialization
39
41
  include Castkit::Ext::DataObject::Deserialization
@@ -56,12 +58,14 @@ module Castkit
56
58
 
57
59
  # Gets or sets the serializer class to use for instances of this object.
58
60
  #
59
- # @param value [Class<Castkit::Serializer>, nil]
60
- # @return [Class<Castkit::Serializer>, nil]
61
- # @raise [ArgumentError] if value does not inherit from Castkit::Serializer
61
+ # @param value [Class<Castkit::Serializers::Base>, nil]
62
+ # @return [Class<Castkit::Serializers::Base>, nil]
63
+ # @raise [ArgumentError] if value does not inherit from Castkit::Serializers::Base
62
64
  def serializer(value = nil)
63
65
  if value
64
- raise ArgumentError, "Serializer must inherit from Castkit::Serializer" unless value < Castkit::Serializer
66
+ unless value < Castkit::Serializers::Base
67
+ raise ArgumentError, "Serializer must inherit from Castkit::Serializers::Base"
68
+ end
65
69
 
66
70
  @serializer = value
67
71
  else
@@ -154,9 +158,9 @@ module Castkit
154
158
 
155
159
  # Returns the serializer instance or default for this object.
156
160
  #
157
- # @return [Class<Castkit::Serializer>]
161
+ # @return [Class<Castkit::Serializers::Base>]
158
162
  def serializer
159
- @serializer ||= self.class.serializer || Castkit::DefaultSerializer
163
+ @serializer ||= self.class.serializer || Castkit::Serializers::DefaultSerializer
160
164
  end
161
165
 
162
166
  # Returns false if self.class.allow_unknown == true, otherwise the value of self.class.strict.
@@ -63,7 +63,7 @@ module Castkit
63
63
  # UserDto = Castkit::DataObject.from_contract(UserContract)
64
64
  # dto = UserDto.new(id: "abc", email: "a@example.com")
65
65
  #
66
- # @param contract [Class<Castkit::Contract::Generic>] the contract to convert
66
+ # @param contract [Class<Castkit::Contract::Base>] the contract to convert
67
67
  # @return [Class<Castkit::DataObject>] a new anonymous DataObject class
68
68
 
69
69
  def from_contract(contract)
@@ -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
@@ -24,7 +24,7 @@ module Castkit
24
24
  # @param string [String, Symbol] the input to convert
25
25
  # @return [String] the PascalCase representation
26
26
  def pascalize(string)
27
- string.to_s.split("_").map(&:capitalize).join
27
+ underscore(string).to_s.split("_").map(&:capitalize).join
28
28
  end
29
29
 
30
30
  # Converts a PascalCase or camelCase string to snake_case.
@@ -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