castkit 0.2.0 → 0.3.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +297 -13
- data/castkit.gemspec +3 -0
- data/lib/castkit/attribute.rb +82 -59
- data/lib/castkit/attributes/definition.rb +64 -0
- data/lib/castkit/attributes/options.rb +214 -0
- data/lib/castkit/castkit.rb +18 -5
- data/lib/castkit/cli/generate.rb +112 -0
- data/lib/castkit/cli/list.rb +200 -0
- data/lib/castkit/cli/main.rb +43 -0
- data/lib/castkit/cli.rb +24 -0
- data/lib/castkit/configuration.rb +31 -8
- data/lib/castkit/contract/{generic.rb → base.rb} +5 -17
- data/lib/castkit/contract/result.rb +2 -2
- data/lib/castkit/contract/validator.rb +5 -1
- data/lib/castkit/contract.rb +5 -5
- data/lib/castkit/core/attributes.rb +87 -44
- data/lib/castkit/data_object.rb +11 -30
- data/lib/castkit/{ext → dsl}/attribute/access.rb +1 -1
- data/lib/castkit/{ext → dsl}/attribute/error_handling.rb +1 -1
- data/lib/castkit/{ext → dsl}/attribute/options.rb +1 -1
- data/lib/castkit/{ext → dsl}/attribute/validation.rb +3 -3
- data/lib/castkit/dsl/attribute.rb +47 -0
- data/lib/castkit/{ext → dsl}/data_object/contract.rb +2 -2
- data/lib/castkit/{ext → dsl}/data_object/deserialization.rb +6 -2
- data/lib/castkit/dsl/data_object/plugins.rb +86 -0
- data/lib/castkit/{ext → dsl}/data_object/serialization.rb +1 -1
- data/lib/castkit/dsl/data_object.rb +61 -0
- data/lib/castkit/inflector.rb +1 -1
- data/lib/castkit/plugins.rb +82 -0
- data/lib/castkit/serializers/base.rb +94 -0
- data/lib/castkit/serializers/default_serializer.rb +156 -0
- data/lib/castkit/types/{generic.rb → base.rb} +30 -10
- data/lib/castkit/types/boolean.rb +14 -10
- data/lib/castkit/types/collection.rb +13 -2
- data/lib/castkit/types/date.rb +2 -2
- data/lib/castkit/types/date_time.rb +2 -2
- data/lib/castkit/types/float.rb +5 -5
- data/lib/castkit/types/integer.rb +5 -5
- data/lib/castkit/types/string.rb +2 -2
- data/lib/castkit/types.rb +1 -1
- data/lib/castkit/validators/base.rb +59 -0
- data/lib/castkit/validators/boolean_validator.rb +39 -0
- data/lib/castkit/validators/collection_validator.rb +29 -0
- data/lib/castkit/validators/float_validator.rb +31 -0
- data/lib/castkit/validators/integer_validator.rb +31 -0
- data/lib/castkit/validators/numeric_validator.rb +2 -2
- data/lib/castkit/validators/string_validator.rb +3 -4
- data/lib/castkit/version.rb +1 -1
- data/lib/castkit.rb +1 -4
- data/lib/generators/attribute.rb +39 -0
- data/lib/generators/base.rb +97 -0
- data/lib/generators/contract.rb +68 -0
- data/lib/generators/data_object.rb +48 -0
- data/lib/generators/plugin.rb +25 -0
- data/lib/generators/serializer.rb +28 -0
- data/lib/generators/templates/attribute.rb.tt +21 -0
- data/lib/generators/templates/attribute_spec.rb.tt +41 -0
- data/lib/generators/templates/contract.rb.tt +26 -0
- data/lib/generators/templates/contract_spec.rb.tt +76 -0
- data/lib/generators/templates/data_object.rb.tt +17 -0
- data/lib/generators/templates/data_object_spec.rb.tt +36 -0
- data/lib/generators/templates/plugin.rb.tt +37 -0
- data/lib/generators/templates/plugin_spec.rb.tt +18 -0
- data/lib/generators/templates/serializer.rb.tt +24 -0
- data/lib/generators/templates/serializer_spec.rb.tt +14 -0
- data/lib/generators/templates/type.rb.tt +57 -0
- data/lib/generators/templates/type_spec.rb.tt +42 -0
- data/lib/generators/templates/validator.rb.tt +26 -0
- data/lib/generators/templates/validator_spec.rb.tt +23 -0
- data/lib/generators/type.rb +29 -0
- data/lib/generators/validator.rb +41 -0
- metadata +92 -16
- data/.rspec_status +0 -196
- data/lib/castkit/core/registerable.rb +0 -59
- data/lib/castkit/default_serializer.rb +0 -154
- data/lib/castkit/serializer.rb +0 -92
- data/lib/castkit/validators/base_validator.rb +0 -39
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "attribute/options"
|
|
4
|
+
require_relative "attribute/access"
|
|
5
|
+
require_relative "attribute/validation"
|
|
6
|
+
|
|
7
|
+
module Castkit
|
|
8
|
+
module DSL
|
|
9
|
+
# Provides a unified entry point for attribute-level DSL extensions.
|
|
10
|
+
#
|
|
11
|
+
# This module bundles together the core DSL modules for configuring attributes.
|
|
12
|
+
# It is included internally by systems that support Castkit-style attribute declarations,
|
|
13
|
+
# such as {Castkit::DataObject} and {Castkit::Contract::Base}.
|
|
14
|
+
#
|
|
15
|
+
# When included, it mixes in:
|
|
16
|
+
# - {Castkit::DSL::Attribute::Options} – option-setting methods (e.g., `required`, `default`, etc.)
|
|
17
|
+
# - {Castkit::DSL::Attribute::Access} – access control methods (e.g., `readonly`, `access`)
|
|
18
|
+
# - {Castkit::DSL::Attribute::Validation} – validation helpers (e.g., `format`, `validator`)
|
|
19
|
+
#
|
|
20
|
+
# @example Extending a custom DSL that uses Castkit-style attributes
|
|
21
|
+
# class MyCustomSchema
|
|
22
|
+
# include Castkit::DSL::Attribute
|
|
23
|
+
#
|
|
24
|
+
# def self.required(value)
|
|
25
|
+
# # interpret DSL options
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# class MyString < MyCustomSchema
|
|
30
|
+
# type :string
|
|
31
|
+
# required true
|
|
32
|
+
# access [:read]
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @note This module is not intended to be mixed into {Castkit::Attributes::Definition}.
|
|
36
|
+
module Attribute
|
|
37
|
+
# Hook called when this module is included.
|
|
38
|
+
#
|
|
39
|
+
# @param base [Class, Module] the including class or module
|
|
40
|
+
def self.included(base)
|
|
41
|
+
base.include(Castkit::DSL::Attribute::Options)
|
|
42
|
+
base.include(Castkit::DSL::Attribute::Access)
|
|
43
|
+
base.include(Castkit::DSL::Attribute::Validation)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "../../contract"
|
|
4
4
|
|
|
5
5
|
module Castkit
|
|
6
|
-
module
|
|
6
|
+
module DSL
|
|
7
7
|
module DataObject
|
|
8
8
|
# Extension module that adds contract support to Castkit::DataObject classes.
|
|
9
9
|
#
|
|
@@ -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::
|
|
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)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Castkit
|
|
4
|
-
module
|
|
4
|
+
module DSL
|
|
5
5
|
module DataObject
|
|
6
6
|
# Adds deserialization support for Castkit::DataObject instances.
|
|
7
7
|
#
|
|
@@ -104,7 +104,11 @@ module Castkit
|
|
|
104
104
|
# @return [Object, nil]
|
|
105
105
|
def resolve_input_value(input, attribute)
|
|
106
106
|
attribute.key_path(with_aliases: true).each do |path|
|
|
107
|
-
value = path.reduce(input)
|
|
107
|
+
value = path.reduce(input) do |memo, key|
|
|
108
|
+
next memo unless memo.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
memo.key?(key) ? memo[key] : memo[key.to_s]
|
|
111
|
+
end
|
|
108
112
|
return value unless value.nil?
|
|
109
113
|
end
|
|
110
114
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Castkit
|
|
4
|
+
module DSL
|
|
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
|
+
require_relative "../core/config"
|
|
4
|
+
require_relative "../core/attributes"
|
|
5
|
+
require_relative "../core/attribute_types"
|
|
6
|
+
require_relative "data_object/contract"
|
|
7
|
+
require_relative "data_object/plugins"
|
|
8
|
+
require_relative "data_object/serialization"
|
|
9
|
+
require_relative "data_object/deserialization"
|
|
10
|
+
|
|
11
|
+
module Castkit
|
|
12
|
+
module DSL
|
|
13
|
+
# Provides the complete DSL used by Castkit data objects.
|
|
14
|
+
#
|
|
15
|
+
# This module can be included into any class to make it behave like a `Castkit::DataObject`
|
|
16
|
+
# without requiring subclassing. It wires in the full attribute DSL, type system, contract support,
|
|
17
|
+
# plugin lifecycle, and (de)serialization logic.
|
|
18
|
+
#
|
|
19
|
+
# This is what powers `Castkit::DataObject` internally, and is intended for advanced use
|
|
20
|
+
# cases where composition is preferred over inheritance.
|
|
21
|
+
#
|
|
22
|
+
# When included, this module:
|
|
23
|
+
#
|
|
24
|
+
# - `extend`s:
|
|
25
|
+
# - {Castkit::Core::Config} – configuration and context behavior
|
|
26
|
+
# - {Castkit::Core::Attributes} – the DSL for declaring attributes
|
|
27
|
+
# - {Castkit::Core::AttributeTypes} – support for custom type resolution
|
|
28
|
+
# - {Castkit::DSL::DataObject::Contract} – validation contract hooks
|
|
29
|
+
# - {Castkit::DSL::DataObject::Plugins} – plugin hooks and lifecycle events
|
|
30
|
+
#
|
|
31
|
+
# - `include`s:
|
|
32
|
+
# - {Castkit::DSL::DataObject::Serialization} – `#to_h`, `#as_json`, etc.
|
|
33
|
+
# - {Castkit::DSL::DataObject::Deserialization} – `from_h`, `from_json`, etc.
|
|
34
|
+
#
|
|
35
|
+
# @example Including in a custom data object
|
|
36
|
+
# class MyObject
|
|
37
|
+
# include Castkit::DSL::DataObject
|
|
38
|
+
#
|
|
39
|
+
# string :id
|
|
40
|
+
# boolean :active, default: true
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# @see Castkit::DataObject for the default implementation
|
|
44
|
+
module DataObject
|
|
45
|
+
# Hook triggered when the module is included.
|
|
46
|
+
#
|
|
47
|
+
# @param base [Class] the including class
|
|
48
|
+
# @return [void]
|
|
49
|
+
def self.included(base)
|
|
50
|
+
base.extend(Castkit::Core::Config)
|
|
51
|
+
base.extend(Castkit::Core::Attributes)
|
|
52
|
+
base.extend(Castkit::Core::AttributeTypes)
|
|
53
|
+
base.extend(Castkit::DSL::DataObject::Contract)
|
|
54
|
+
base.extend(Castkit::DSL::DataObject::Plugins)
|
|
55
|
+
|
|
56
|
+
base.include(Castkit::DSL::DataObject::Serialization)
|
|
57
|
+
base.include(Castkit::DSL::DataObject::Deserialization)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/castkit/inflector.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Castkit
|
|
4
4
|
module Types
|
|
5
|
-
#
|
|
5
|
+
# Abstract base class for type definitions in Castkit.
|
|
6
6
|
#
|
|
7
7
|
# Provides default behavior for (de)serialization, validation, and coercion.
|
|
8
8
|
# All primitive types should subclass this and override methods as needed.
|
|
9
9
|
#
|
|
10
10
|
# The `cast!` method is the primary entry point used by attribute processing
|
|
11
11
|
# to validate and coerce values in a predictable order.
|
|
12
|
-
class
|
|
12
|
+
class Base
|
|
13
13
|
class << self
|
|
14
14
|
# Coerces and validates a value for use in a Castkit DataObject.
|
|
15
15
|
#
|
|
@@ -24,18 +24,19 @@ module Castkit
|
|
|
24
24
|
# @param options [Hash] options passed to `validate!`, e.g., `min`, `max`, `force_type`
|
|
25
25
|
# @param context [Symbol, String, nil] context label for error messages
|
|
26
26
|
# @return [Object] the deserialized and validated value
|
|
27
|
-
def cast!(value, validator: nil, options: {}, context: {})
|
|
27
|
+
def cast!(value, validator: nil, options: {}, context: {}, **extra_options)
|
|
28
|
+
options = options.merge(extra_options)
|
|
28
29
|
instance = new
|
|
29
30
|
validator ||= options.delete(:validator)
|
|
30
31
|
validator ||= default_validator(instance)
|
|
31
32
|
|
|
32
33
|
if options[:force_type]
|
|
33
34
|
deserialized_value = instance.deserialize(value)
|
|
34
|
-
validator
|
|
35
|
+
invoke_validator(validator, deserialized_value, options: options, context: context)
|
|
35
36
|
return deserialized_value
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
validator
|
|
39
|
+
invoke_validator(validator, value, options: options, context: context)
|
|
39
40
|
instance.deserialize(value)
|
|
40
41
|
end
|
|
41
42
|
|
|
@@ -69,13 +70,33 @@ module Castkit
|
|
|
69
70
|
|
|
70
71
|
# Builds a default validator from the instance itself.
|
|
71
72
|
#
|
|
72
|
-
# @param instance [Castkit::Types::
|
|
73
|
+
# @param instance [Castkit::Types::Base]
|
|
73
74
|
# @return [Proc] a lambda wrapping `#validate!`
|
|
74
75
|
def default_validator(instance)
|
|
75
76
|
lambda do |value, options: {}, context: nil|
|
|
76
77
|
instance.validate!(value, options: options, context: context)
|
|
77
78
|
end
|
|
78
79
|
end
|
|
80
|
+
|
|
81
|
+
# Dispatches validation to support callable validators with different arities.
|
|
82
|
+
#
|
|
83
|
+
# @param validator [#call, Proc] the validator to invoke
|
|
84
|
+
# @param value [Object] the value being validated
|
|
85
|
+
# @param options [Hash] validation options
|
|
86
|
+
# @param context [Symbol, String, nil] context for error messages
|
|
87
|
+
# @return [void]
|
|
88
|
+
def invoke_validator(validator, value, options:, context:)
|
|
89
|
+
return validator.call(value, options: options, context: context) unless validator.is_a?(Proc)
|
|
90
|
+
|
|
91
|
+
case validator.arity
|
|
92
|
+
when 1
|
|
93
|
+
validator.call(value)
|
|
94
|
+
when 2
|
|
95
|
+
validator.call(value, options)
|
|
96
|
+
else
|
|
97
|
+
validator.call(value, options: options, context: context)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
79
100
|
end
|
|
80
101
|
|
|
81
102
|
# Deserializes the value. Override in subclasses to coerce input (e.g., `"123"` → `123`).
|
|
@@ -111,10 +132,9 @@ module Castkit
|
|
|
111
132
|
# @param type [Symbol]
|
|
112
133
|
# @param value [Object, nil]
|
|
113
134
|
# @return [void]
|
|
114
|
-
def type_error!(type, value)
|
|
115
|
-
message = "value must be a #{type}, got #{value
|
|
116
|
-
|
|
117
|
-
raise Castkit::TypeError, message if Castkit.configuration.raise_type_errors
|
|
135
|
+
def type_error!(type, value, context: nil)
|
|
136
|
+
message = "#{context || "value"} must be a #{type}, got #{value}"
|
|
137
|
+
raise Castkit::AttributeError, message if Castkit.configuration.raise_type_errors
|
|
118
138
|
|
|
119
139
|
Castkit.warning "[Castkit] #{message}" if Castkit.configuration.enable_warnings
|
|
120
140
|
end
|