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
data/lib/castkit/error.rb CHANGED
@@ -9,13 +9,15 @@ module Castkit
9
9
  # Initializes a Castkit error.
10
10
  #
11
11
  # @param msg [String] the error message
12
- # @param context [Object, nil] optional data object or hash for context
12
+ # @param context [Object, String, nil] optional data object or hash for context
13
13
  def initialize(msg, context: nil)
14
14
  super(msg)
15
15
  @context = context
16
16
  end
17
17
  end
18
18
 
19
+ class TypeError < Error; end
20
+
19
21
  # Raised for issues related to Castkit::DataObject initialization or usage.
20
22
  class DataObjectError < Error; end
21
23
 
@@ -23,9 +25,9 @@ module Castkit
23
25
  class AttributeError < Error
24
26
  # Returns the field name related to the error, if available.
25
27
  #
26
- # @return [Symbol]
28
+ # @return [Symbol, nil]
27
29
  def field
28
- context.is_a?(Hash) ? context[:field] : context || :unknown
30
+ context.is_a?(Hash) ? context[:field] : context || nil
29
31
  end
30
32
 
31
33
  # Formats the error message with field info if available.
@@ -39,4 +41,14 @@ module Castkit
39
41
 
40
42
  # Raised during serialization if an object fails to serialize properly.
41
43
  class SerializationError < Error; end
44
+
45
+ # Raised during contract validation.
46
+ class ContractError < Error
47
+ attr_reader :errors
48
+
49
+ def initialize(msg, context: nil, errors: nil)
50
+ super(msg, context: context)
51
+ @errors = errors || {}
52
+ end
53
+ end
42
54
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module Ext
5
+ module Attribute
6
+ # Provides access control helpers for attributes.
7
+ #
8
+ # These helpers determine whether an attribute is readable, writeable,
9
+ # or should be included during serialization/deserialization based on the
10
+ # configured `:access` and `:ignore` options.
11
+ module Access
12
+ # Returns the normalized access modes for the attribute (e.g., [:read, :write]).
13
+ #
14
+ # @return [Array<Symbol>] list of access symbols
15
+ def access
16
+ Array(options[:access]).map(&:to_sym)
17
+ end
18
+
19
+ # Whether the attribute should be included during serialization.
20
+ #
21
+ # @return [Boolean]
22
+ def readable?
23
+ access.include?(:read)
24
+ end
25
+
26
+ # Whether the attribute should be accepted during deserialization.
27
+ #
28
+ # Composite attributes are excluded from writeability.
29
+ #
30
+ # @return [Boolean]
31
+ def writeable?
32
+ access.include?(:write) && !composite?
33
+ end
34
+
35
+ # Whether the attribute is both readable and writeable.
36
+ #
37
+ # @return [Boolean]
38
+ def full_access?
39
+ readable? && writeable?
40
+ end
41
+
42
+ # Whether the attribute should be skipped during serialization.
43
+ #
44
+ # This is true if it's not readable or is marked as ignored.
45
+ #
46
+ # @return [Boolean]
47
+ def skip_serialization?
48
+ !readable? || ignore?
49
+ end
50
+
51
+ # Whether the attribute should be skipped during deserialization.
52
+ #
53
+ # @return [Boolean]
54
+ def skip_deserialization?
55
+ !writeable?
56
+ end
57
+
58
+ # Whether the attribute is ignored completely.
59
+ #
60
+ # @return [Boolean]
61
+ def ignore?
62
+ options[:ignore]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module Ext
5
+ module Attribute
6
+ # Provides centralized handling of attribute casting and validation errors.
7
+ #
8
+ # The behavior of each error is controlled by configuration flags in `Castkit.configuration`.
9
+ module ErrorHandling
10
+ # Maps known error types to their handling behavior.
11
+ #
12
+ # Each entry includes:
13
+ # - `:config` – the config flag that determines enforcement
14
+ # - `:message` – a lambda that generates an error message
15
+ # - `:error` – the error class to raise
16
+ #
17
+ # @return [Hash{Symbol => Hash}]
18
+ ERROR_OPTIONS = {
19
+ access: {
20
+ config: :enforce_attribute_access,
21
+ message: ->(_attr, mode:) { "invalid access mode `#{mode}`" },
22
+ error: Castkit::AttributeError
23
+ },
24
+ unwrapped: {
25
+ config: :enforce_unwrapped_prefix,
26
+ message: ->(*_) { "prefix can only be used with unwrapped attribute" },
27
+ error: Castkit::AttributeError
28
+ },
29
+ array_options: {
30
+ config: :enforce_array_options,
31
+ message: ->(*_) { "array attribute must specify `of:` type" },
32
+ error: Castkit::AttributeError
33
+ }
34
+ }.freeze
35
+
36
+ private
37
+
38
+ # Handles a validation or casting error based on the provided error key and context.
39
+ #
40
+ # If the corresponding configuration flag is enabled, an exception is raised.
41
+ # Otherwise, a warning is logged and the method returns `nil`.
42
+ #
43
+ # @param key [Symbol] the type of error (must match a key in ERROR_OPTIONS)
44
+ # @param kwargs [Hash] additional values passed to the message lambda
45
+ # @option kwargs [Symbol] :context (optional) the attribute context (e.g., field name)
46
+ # @return [nil]
47
+ # @raise [Castkit::AttributeError] if enforcement is enabled for the given error type
48
+ def handle_error(key, **kwargs)
49
+ config_key = ERROR_OPTIONS.dig(key, :config)
50
+ message_fn = ERROR_OPTIONS.dig(key, :message)
51
+ error_class = ERROR_OPTIONS.dig(key, :error) || Castkit::Error
52
+
53
+ context = kwargs.delete(:context)
54
+ message = message_fn.call(self, **kwargs)
55
+ raise error_class.new(message, context: context) if Castkit.configuration.public_send(config_key)
56
+
57
+ Castkit.warning "[Castkit] #{message}"
58
+ nil
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../data_object"
4
+
5
+ module Castkit
6
+ module Ext
7
+ module Attribute
8
+ # Provides access to normalized attribute options and helper predicates.
9
+ #
10
+ # These methods support Castkit attribute behavior such as default values,
11
+ # key mapping, optionality, and structural roles (e.g. composite or unwrapped).
12
+ module Options
13
+ # Default options for attributes.
14
+ #
15
+ # @return [Hash{Symbol => Object}]
16
+ DEFAULT_OPTIONS = {
17
+ required: true,
18
+ ignore_nil: false,
19
+ ignore_blank: false,
20
+ ignore: false,
21
+ composite: false,
22
+ transient: false,
23
+ unwrapped: false,
24
+ prefix: nil,
25
+ access: %i[read write],
26
+ force_type: !Castkit.configuration.enforce_typing
27
+ }.freeze
28
+
29
+ # Returns the default value for the attribute.
30
+ #
31
+ # If the default is callable, it is invoked.
32
+ #
33
+ # @return [Object]
34
+ def default
35
+ val = @default
36
+ val.respond_to?(:call) ? val.call : val
37
+ end
38
+
39
+ # Returns the serialization/deserialization key.
40
+ #
41
+ # Falls back to the field name if `:key` is not specified.
42
+ #
43
+ # @return [Symbol, String]
44
+ def key
45
+ options[:key] || field
46
+ end
47
+
48
+ # Returns the key path for accessing nested keys.
49
+ #
50
+ # Optionally includes alias key paths if `with_aliases` is true.
51
+ #
52
+ # @param with_aliases [Boolean]
53
+ # @return [Array<Array<Symbol>>] nested key paths
54
+ def key_path(with_aliases: false)
55
+ path = key.to_s.split(".").map(&:to_sym) || []
56
+ return path unless with_aliases
57
+
58
+ [path] + alias_paths
59
+ end
60
+
61
+ # Returns all alias key paths as arrays of symbols.
62
+ #
63
+ # @return [Array<Array<Symbol>>]
64
+ def alias_paths
65
+ options[:aliases].map { |a| a.to_s.split(".").map(&:to_sym) }
66
+ end
67
+
68
+ # Whether the attribute is required for object construction.
69
+ #
70
+ # @return [Boolean]
71
+ def required?
72
+ options[:required]
73
+ end
74
+
75
+ # Whether the attribute is optional.
76
+ #
77
+ # @return [Boolean]
78
+ def optional?
79
+ !required?
80
+ end
81
+
82
+ # Whether to ignore `nil` values during serialization.
83
+ #
84
+ # @return [Boolean]
85
+ def ignore_nil?
86
+ options[:ignore_nil]
87
+ end
88
+
89
+ # Whether to ignore blank values (`[]`, `{}`, empty strings) during serialization.
90
+ #
91
+ # @return [Boolean]
92
+ def ignore_blank?
93
+ options[:ignore_blank]
94
+ end
95
+
96
+ # Whether the attribute is a nested Castkit::DataObject.
97
+ #
98
+ # @return [Boolean]
99
+ def dataobject?
100
+ Castkit.dataobject?(type)
101
+ end
102
+
103
+ # Whether the attribute is a collection of Castkit::DataObjects.
104
+ #
105
+ # @return [Boolean]
106
+ def dataobject_collection?
107
+ type == :array && Castkit.dataobject?(options[:of])
108
+ end
109
+
110
+ # Whether the attribute is considered composite.
111
+ #
112
+ # @return [Boolean]
113
+ def composite?
114
+ options[:composite]
115
+ end
116
+
117
+ # Whether the attribute is considered transient (not exposed in serialized output).
118
+ #
119
+ # @return [Boolean]
120
+ def transient?
121
+ options[:transient]
122
+ end
123
+
124
+ # Whether the attribute is unwrapped into the parent object.
125
+ #
126
+ # Only applies to Castkit::DataObject types.
127
+ #
128
+ # @return [Boolean]
129
+ def unwrapped?
130
+ dataobject? && options[:unwrapped]
131
+ end
132
+
133
+ # Returns the prefix used for unwrapped attributes.
134
+ #
135
+ # @return [String, nil]
136
+ def prefix
137
+ options[:prefix]
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error_handling"
4
+ require_relative "options"
5
+
6
+ module Castkit
7
+ module Ext
8
+ module Attribute
9
+ # Provides validation logic for attribute configuration.
10
+ #
11
+ # These checks are typically performed at attribute initialization to catch misconfigurations early.
12
+ module Validation
13
+ include Castkit::Ext::Attribute::ErrorHandling
14
+
15
+ private
16
+
17
+ # Runs all validation checks on the attribute definition.
18
+ #
19
+ # This includes:
20
+ # - Custom validator integrity
21
+ # - Access mode validity
22
+ # - Unwrapped prefix usage
23
+ # - Array `of:` type presence
24
+ #
25
+ # @return [void]
26
+ def validate!
27
+ validate_type!
28
+ validate_custom_validator!
29
+ validate_access!
30
+ validate_unwrapped_options!
31
+ validate_array_options!
32
+ end
33
+
34
+ def validate_type!
35
+ types ||= Array(type) # used to test single type and type unions.
36
+
37
+ types.each do |t|
38
+ next if Castkit.dataobject?(t) || Castkit.configuration.type_registered?(t)
39
+
40
+ raise_error!("Type is not registered, register with Castkit.configuration.register_type(:#{t})")
41
+ end
42
+ end
43
+
44
+ # Validates the presence and interface of a custom validator.
45
+ #
46
+ # @return [void]
47
+ # @raise [Castkit::AttributeError] if the validator is not callable
48
+ def validate_custom_validator!
49
+ return unless options[:validator]
50
+ return if options[:validator].respond_to?(:call)
51
+
52
+ raise_error!("Custom validator for `#{field}` must respond to `.call`")
53
+ end
54
+
55
+ # Validates that each declared access mode is valid.
56
+ #
57
+ # @return [void]
58
+ # @raise [Castkit::AttributeError] if any access mode is invalid and enforcement is enabled
59
+ def validate_access!
60
+ access.each do |mode|
61
+ next if Castkit::Ext::Attribute::Options::DEFAULT_OPTIONS[:access].include?(mode)
62
+
63
+ handle_error(:access, mode: mode, context: to_h)
64
+ end
65
+ end
66
+
67
+ # Ensures prefix is only used with unwrapped attributes.
68
+ #
69
+ # @return [void]
70
+ # @raise [Castkit::AttributeError] if prefix is used without `unwrapped: true`
71
+ def validate_unwrapped_options!
72
+ handle_error(:unwrapped, context: to_h) if prefix && !unwrapped?
73
+ end
74
+
75
+ # Ensures `of:` is provided for array-typed attributes.
76
+ #
77
+ # @return [void]
78
+ # @raise [Castkit::AttributeError] if `of:` is missing for `type: :array`
79
+ def validate_array_options!
80
+ handle_error(:array_options, context: to_h) if type == :array && options[:of].nil?
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -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::Base>] 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