castkit 0.1.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.
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "data_object"
5
+ require_relative "attribute_extensions/options"
6
+ require_relative "attribute_extensions/casting"
7
+ require_relative "attribute_extensions/access"
8
+ require_relative "attribute_extensions/validation"
9
+ require_relative "attribute_extensions/serialization"
10
+
11
+ module Castkit
12
+ # Represents a typed attribute on a Castkit::DataObject.
13
+ #
14
+ # Provides casting, validation, access control, and serialization behavior.
15
+ class Attribute
16
+ include Castkit::AttributeExtensions::Options
17
+ include Castkit::AttributeExtensions::Casting
18
+ include Castkit::AttributeExtensions::Access
19
+ include Castkit::AttributeExtensions::Validation
20
+ include Castkit::AttributeExtensions::Serialization
21
+
22
+ # @return [Symbol] the attribute name
23
+ attr_reader :field
24
+
25
+ # @return [Symbol, Class, Array] the declared type (normalized)
26
+ attr_reader :type
27
+
28
+ # @return [Hash] attribute options (including aliases, default, access, etc.)
29
+ attr_reader :options
30
+
31
+ # Initializes a new attribute definition.
32
+ #
33
+ # @param field [Symbol] the name of the attribute
34
+ # @param type [Symbol, Class, Array] the type or array of types
35
+ # @param default [Object, Proc] optional default value
36
+ # @param options [Hash] additional configuration options
37
+ def initialize(field, type, default: nil, **options)
38
+ @field = field
39
+ @type = normalize_type(type)
40
+ @default = default
41
+ @options = populate_options(options)
42
+
43
+ validate!
44
+ end
45
+
46
+ # Returns a hash representation of the attribute definition.
47
+ #
48
+ # @return [Hash]
49
+ def to_hash
50
+ {
51
+ field: field,
52
+ type: type,
53
+ options: options,
54
+ default: default
55
+ }
56
+ end
57
+
58
+ # @see #to_hash
59
+ alias to_h to_hash
60
+
61
+ private
62
+
63
+ # Populates default values and normalizes internal options.
64
+ #
65
+ # @param options [Hash]
66
+ # @return [Hash]
67
+ def populate_options(options)
68
+ options = DEFAULT_OPTIONS.merge(options)
69
+ options[:aliases] = Array(options[:aliases] || [])
70
+ options[:of] = normalize_type(options[:of]) if options[:of]
71
+
72
+ options
73
+ end
74
+
75
+ # Normalizes a declared type to a symbol or class reference.
76
+ #
77
+ # @param type [Symbol, Class, Array]
78
+ # @return [Symbol, Class, Array]
79
+ # @raise [Castkit::AttributeError] if the type is not valid
80
+ def normalize_type(type)
81
+ return type.map { |t| normalize_type(t) } if type.is_a?(Array)
82
+ return type if Castkit.dataobject?(type)
83
+
84
+ process_type(type)
85
+ end
86
+
87
+ # Converts a single type value into a normalized internal representation.
88
+ #
89
+ # - Maps `TrueClass`/`FalseClass` to `:boolean`
90
+ # - Converts class names (e.g., `String`, `Integer`) to lowercase symbols
91
+ # - Accepts already-symbolized types (e.g., `:string`)
92
+ #
93
+ # @param type [Class, Symbol] the declared type to process
94
+ # @return [Symbol] the normalized type
95
+ # @raise [Castkit::AttributeError] if the type is not a recognized form
96
+ def process_type(type)
97
+ case type
98
+ when Class
99
+ return :boolean if [TrueClass, FalseClass].include?(type)
100
+
101
+ type.name.downcase.to_sym
102
+ when Symbol
103
+ type
104
+ else
105
+ raise_error!("Unknown type: #{type.inspect}")
106
+ end
107
+ end
108
+
109
+ # Validates the final value against a validator if required.
110
+ #
111
+ # @param value [Object]
112
+ # @param context [Symbol, String]
113
+ # @return [void]
114
+ def validate_value!(value, context:)
115
+ return if value.nil? && optional?
116
+ return if type.is_a?(Array) || dataobject?
117
+
118
+ validator = options[:validator] || Castkit.configuration.validator_for(type)
119
+ validator&.call(value, options: options, context: context)
120
+ end
121
+
122
+ # Raises a Castkit::AttributeError with optional context.
123
+ #
124
+ # @param message [String]
125
+ # @param context [Hash, nil]
126
+ # @raise [Castkit::AttributeError]
127
+ def raise_error!(message, context: nil)
128
+ raise Castkit::AttributeError.new(message, context: context || to_h)
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module AttributeExtensions
5
+ # Provides access control helpers for attributes.
6
+ #
7
+ # These helpers determine whether an attribute is readable, writeable,
8
+ # or should be included during serialization/deserialization based on the
9
+ # configured `:access` and `:ignore` options.
10
+ module Access
11
+ # Returns the normalized access modes for the attribute (e.g., [:read, :write]).
12
+ #
13
+ # @return [Array<Symbol>] list of access symbols
14
+ def access
15
+ Array(options[:access]).map(&:to_sym)
16
+ end
17
+
18
+ # Whether the attribute should be included during serialization.
19
+ #
20
+ # @return [Boolean]
21
+ def readable?
22
+ access.include?(:read)
23
+ end
24
+
25
+ # Whether the attribute should be accepted during deserialization.
26
+ #
27
+ # Composite attributes are excluded from writeability.
28
+ #
29
+ # @return [Boolean]
30
+ def writeable?
31
+ access.include?(:write) && !composite?
32
+ end
33
+
34
+ # Whether the attribute is both readable and writeable.
35
+ #
36
+ # @return [Boolean]
37
+ def full_access?
38
+ readable? && writeable?
39
+ end
40
+
41
+ # Whether the attribute should be skipped during serialization.
42
+ #
43
+ # This is true if it's not readable or is marked as ignored.
44
+ #
45
+ # @return [Boolean]
46
+ def skip_serialization?
47
+ !readable? || ignore?
48
+ end
49
+
50
+ # Whether the attribute should be skipped during deserialization.
51
+ #
52
+ # @return [Boolean]
53
+ def skip_deserialization?
54
+ !writeable?
55
+ end
56
+
57
+ # Whether the attribute is ignored completely.
58
+ #
59
+ # @return [Boolean]
60
+ def ignore?
61
+ options[:ignore]
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../data_object"
5
+ require_relative "error_handling"
6
+
7
+ module Castkit
8
+ module AttributeExtensions
9
+ # Provides typecasting logic for attributes based on their declared type.
10
+ #
11
+ # Supports primitive types, arrays, nested Castkit::DataObject types, and union types.
12
+ module Casting
13
+ include Castkit::AttributeExtensions::ErrorHandling
14
+
15
+ PRIMITIVE_CASTERS = {
16
+ integer: lambda(&:to_i),
17
+ float: lambda(&:to_f),
18
+ string: lambda(&:to_s),
19
+ hash: ->(v) { v },
20
+ array: ->(v) { Array(v) }
21
+ }.freeze
22
+
23
+ private
24
+
25
+ # Casts a value based on the attribute's declared type.
26
+ #
27
+ # @param value [Object] the input value to cast
28
+ # @return [Object, nil] the cast value
29
+ # @raise [Castkit::AttributeError] if the value cannot be cast
30
+ def cast(value)
31
+ handle_error(:array_of_type) if type == :array && options[:of].nil?
32
+ value = default if value.nil?
33
+ return if value.nil? && optional?
34
+
35
+ cast_value(value)
36
+ end
37
+
38
+ # Delegates casting logic based on the attribute's type.
39
+ #
40
+ # This method is invoked internally by `#cast` after nil handling and default fallback.
41
+ #
42
+ # - For union types (`Array` of types), attempts each in order.
43
+ # - For array types, maps over elements with `#cast_element`.
44
+ # - For nested data objects, delegates to `.cast`.
45
+ # - For primitive types, uses `#cast_primitive`.
46
+ #
47
+ # @param value [Object] the raw input value to cast
48
+ # @return [Object, nil] the cast value
49
+ # @raise [Castkit::AttributeError] if casting fails for all union types or invalid primitives
50
+ def cast_value(value)
51
+ if type.is_a?(Array)
52
+ try_union_cast(value)
53
+ elsif type == :array
54
+ Array(value).map { |v| cast_element(v) }
55
+ elsif dataobject?
56
+ type.cast(value)
57
+ else
58
+ cast_primitive(value)
59
+ end
60
+ end
61
+
62
+ # Attempts to cast the value against a union of possible types.
63
+ #
64
+ # @param value [Object]
65
+ # @return [Object] the first successful cast result
66
+ # @raise [Castkit::AttributeError] if no types match
67
+ def try_union_cast(value)
68
+ last_error = nil
69
+
70
+ type.each do |t|
71
+ return try_cast_type(value, t)
72
+ rescue Castkit::AttributeError => e
73
+ last_error = e
74
+ end
75
+
76
+ raise last_error || handle_error(:union, types: type)
77
+ end
78
+
79
+ # Tries to cast a value to a specific type.
80
+ #
81
+ # @param value [Object]
82
+ # @param type [Symbol, Class] the type to try
83
+ # @return [Object, nil]
84
+ def try_cast_type(value, type)
85
+ return type.cast(value) if Castkit.dataobject?(type)
86
+
87
+ cast_primitive(value, type: type)
88
+ end
89
+
90
+ # Casts an element of an array attribute.
91
+ #
92
+ # @param value [Object]
93
+ # @return [Object, nil]
94
+ def cast_element(value)
95
+ if Castkit.dataobject?(options[:of])
96
+ options[:of].cast(value)
97
+ else
98
+ validate_element_type!(value)
99
+ cast_primitive(value, type: options[:of])
100
+ end
101
+ end
102
+
103
+ # Casts a primitive value based on its type.
104
+ #
105
+ # @param value [Object]
106
+ # @param type [Symbol]
107
+ # @return [Object, nil]
108
+ # @raise [Castkit::AttributeError]
109
+ def cast_primitive(value, type: self.type)
110
+ return cast_boolean(value) if type == :boolean
111
+ return Date.parse(value.to_s) if type == :date
112
+ return DateTime.parse(value.to_s) if type == :datetime
113
+
114
+ PRIMITIVE_CASTERS.fetch(type) do
115
+ handle_error(:primitive, type: type)
116
+ end.call(value)
117
+ end
118
+
119
+ # Casts a value to boolean.
120
+ #
121
+ # @param value [Object]
122
+ # @return [Boolean, nil]
123
+ # @raise [Castkit::AttributeError]
124
+ def cast_boolean(value)
125
+ case value.to_s.downcase
126
+ when "true", "1"
127
+ true
128
+ when "false", "0"
129
+ false
130
+ else
131
+ handle_error(:boolean, value: value)
132
+ end
133
+ end
134
+
135
+ # Validates element type for arrays, if `enforce_array_of_type` is enabled.
136
+ #
137
+ # @param value [Object]
138
+ # @return [void]
139
+ def validate_element_type!(value)
140
+ return unless Castkit.configuration.enforce_array_of_type
141
+
142
+ validator = Castkit.configuration.validator_for(options[:of])
143
+ validator.call(value, options: options, context: "#{field}[]")
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../castkit"
4
+
5
+ module Castkit
6
+ module AttributeExtensions
7
+ # Provides centralized handling of attribute casting and validation errors.
8
+ #
9
+ # The behavior of each error is controlled by configuration flags in `Castkit.configuration`.
10
+ module ErrorHandling
11
+ # Maps known error types to their handling behavior.
12
+ #
13
+ # Each entry includes:
14
+ # - `:config` – the config flag that determines enforcement
15
+ # - `:message` – a lambda that generates an error message
16
+ # - `:error` – the error class to raise
17
+ #
18
+ # @return [Hash<Symbol, Hash>]
19
+ ERROR_OPTIONS = {
20
+ array_of_type: {
21
+ config: :enforce_array_of_type,
22
+ message: ->(*_) { "`of:` must be provided for array type" },
23
+ error: Castkit::AttributeError
24
+ },
25
+ primitive: {
26
+ config: :enforce_known_primitive_type,
27
+ message: ->(_attr, type:) { "unknown primitive type: #{type.inspect}" },
28
+ error: Castkit::AttributeError
29
+ },
30
+ boolean: {
31
+ config: :enforce_boolean_casting,
32
+ message: ->(_attr, value:) { "must be a boolean, got: #{value.inspect}" },
33
+ error: Castkit::AttributeError
34
+ },
35
+ union: {
36
+ config: :enforce_union_match,
37
+ message: ->(_attr, types:) { "could not be cast to any of #{types.inspect}" },
38
+ error: Castkit::AttributeError
39
+ },
40
+ access: {
41
+ config: :enforce_attribute_access,
42
+ message: ->(_attr, mode:) { "invalid access mode `#{mode}`" },
43
+ error: Castkit::AttributeError
44
+ },
45
+ unwrapped: {
46
+ config: :enforce_unwrapped_prefix,
47
+ message: ->(*_) { "prefix can only be used with unwrapped attribute" },
48
+ error: Castkit::AttributeError
49
+ },
50
+ array_options: {
51
+ config: :enforce_array_options,
52
+ message: ->(*_) { "array attribute must specify `of:` type" },
53
+ error: Castkit::AttributeError
54
+ }
55
+ }.freeze
56
+
57
+ private
58
+
59
+ # Handles a validation or casting error based on the provided error key and context.
60
+ #
61
+ # If the corresponding configuration flag is enabled, an exception is raised.
62
+ # Otherwise, a warning is logged and the method returns `nil`.
63
+ #
64
+ # @param key [Symbol] the type of error (must match a key in ERROR_OPTIONS)
65
+ # @param kwargs [Hash] additional values passed to the message lambda
66
+ # @option kwargs [Symbol] :context (optional) the attribute context (e.g., field name)
67
+ # @return [nil]
68
+ # @raise [Castkit::AttributeError] if enforcement is enabled for the given error type
69
+ def handle_error(key, **kwargs)
70
+ config_key = ERROR_OPTIONS.dig(key, :config)
71
+ message_fn = ERROR_OPTIONS.dig(key, :message)
72
+ error_class = ERROR_OPTIONS.dig(key, :error) || Castkit::Error
73
+
74
+ context = kwargs.delete(:context)
75
+ message = message_fn.call(self, **kwargs)
76
+ raise error_class.new(message, context: context) if Castkit.configuration.public_send(config_key)
77
+
78
+ warn "[Castkit] #{message}"
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../data_object"
4
+
5
+ module Castkit
6
+ module AttributeExtensions
7
+ # Provides access to normalized attribute options and helper predicates.
8
+ #
9
+ # These methods support Castkit attribute behavior such as default values,
10
+ # key mapping, optionality, and structural roles (e.g. composite or unwrapped).
11
+ module Options
12
+ # Default options for attributes.
13
+ #
14
+ # @return [Hash<Symbol, Object>]
15
+ DEFAULT_OPTIONS = {
16
+ required: true,
17
+ ignore_nil: false,
18
+ ignore_blank: false,
19
+ ignore: false,
20
+ composite: false,
21
+ unwrapped: false,
22
+ prefix: nil,
23
+ access: %i[read write]
24
+ }.freeze
25
+
26
+ # Returns the default value for the attribute.
27
+ #
28
+ # If the default is callable, it is invoked.
29
+ #
30
+ # @return [Object]
31
+ def default
32
+ val = @default
33
+ val.respond_to?(:call) ? val.call : val
34
+ end
35
+
36
+ # Returns the serialization/deserialization key.
37
+ #
38
+ # Falls back to the field name if `:key` is not specified.
39
+ #
40
+ # @return [Symbol, String]
41
+ def key
42
+ options[:key] || field
43
+ end
44
+
45
+ # Returns the key path for accessing nested keys.
46
+ #
47
+ # Optionally includes alias key paths if `with_aliases` is true.
48
+ #
49
+ # @param with_aliases [Boolean]
50
+ # @return [Array<Array<Symbol>>] nested key paths
51
+ def key_path(with_aliases: false)
52
+ path = key.to_s.split(".").map(&:to_sym) || []
53
+ return path unless with_aliases
54
+
55
+ [path] + alias_paths
56
+ end
57
+
58
+ # Returns all alias key paths as arrays of symbols.
59
+ #
60
+ # @return [Array<Array<Symbol>>]
61
+ def alias_paths
62
+ options[:aliases].map { |a| a.to_s.split(".").map(&:to_sym) }
63
+ end
64
+
65
+ # Whether the attribute is required for object construction.
66
+ #
67
+ # @return [Boolean]
68
+ def required?
69
+ options[:required]
70
+ end
71
+
72
+ # Whether the attribute is optional.
73
+ #
74
+ # @return [Boolean]
75
+ def optional?
76
+ !required?
77
+ end
78
+
79
+ # Whether to ignore `nil` values during serialization.
80
+ #
81
+ # @return [Boolean]
82
+ def ignore_nil?
83
+ options[:ignore_nil]
84
+ end
85
+
86
+ # Whether to ignore blank values (`[]`, `{}`, empty strings) during serialization.
87
+ #
88
+ # @return [Boolean]
89
+ def ignore_blank?
90
+ options[:ignore_blank]
91
+ end
92
+
93
+ # Whether the attribute is a nested Castkit::DataObject.
94
+ #
95
+ # @return [Boolean]
96
+ def dataobject?
97
+ Castkit.dataobject?(type)
98
+ end
99
+
100
+ # Whether the attribute is considered composite (not exposed in serialized output).
101
+ #
102
+ # @return [Boolean]
103
+ def composite?
104
+ options[:composite]
105
+ end
106
+
107
+ # Whether the attribute is unwrapped into the parent object.
108
+ #
109
+ # Only applies to Castkit::DataObject types.
110
+ #
111
+ # @return [Boolean]
112
+ def unwrapped?
113
+ dataobject? && options[:unwrapped]
114
+ end
115
+
116
+ # Returns the prefix used for unwrapped attributes.
117
+ #
118
+ # @return [String, nil]
119
+ def prefix
120
+ options[:prefix]
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module AttributeExtensions
5
+ # Handles serialization (`dump`) and deserialization (`load`) of attribute values.
6
+ #
7
+ # Supports primitive types, arrays, and nested Castkit::DataObject instances.
8
+ module Serialization
9
+ # Serializes a value into a format suitable for output (e.g., JSON or Hash).
10
+ #
11
+ # If the value is a Castkit::DataObject, a custom serializer is used if configured.
12
+ #
13
+ # @param value [Object] the value to serialize
14
+ # @param visited [Set, nil] used for circular reference detection
15
+ # @return [Object] the serialized value
16
+ def dump(value, visited: nil)
17
+ return value if value.nil?
18
+
19
+ if type == :array
20
+ Array(value).map { |val| dump_element(val, visited: visited) }
21
+ else
22
+ dump_element(value, visited: visited)
23
+ end
24
+ end
25
+
26
+ # Deserializes and validates a value during object instantiation.
27
+ #
28
+ # Applies default value, casts, and runs validators.
29
+ #
30
+ # @param value [Object] the input value
31
+ # @param context [Symbol] the attribute name or context key
32
+ # @return [Object] the deserialized and validated value
33
+ # @raise [Castkit::AttributeError] if value is required but missing
34
+ def load(value, context:)
35
+ value = default if value.nil?
36
+ return raise_error!("#{field} is required for instantiation") if value.nil? && required?
37
+
38
+ value = cast(value)
39
+ validate_value!(value, context: context)
40
+
41
+ value
42
+ end
43
+
44
+ private
45
+
46
+ # Serializes a single element value.
47
+ #
48
+ # - Uses a serializer if the value is a Castkit::DataObject.
49
+ # - Converts `to_h` if the value is hash-like.
50
+ #
51
+ # @param value [Object] the element to dump
52
+ # @param visited [Set, nil]
53
+ # @return [Object]
54
+ def dump_element(value, visited: nil)
55
+ return value if value.nil? || primitive?(value)
56
+
57
+ if value.is_a?(Castkit::DataObject)
58
+ serializer = options[:serializer] || value.class.serializer || Castkit::DefaultSerializer
59
+ serializer.call(value, visited: visited)
60
+ elsif hashable?(value)
61
+ value.to_h(visited)
62
+ else
63
+ value
64
+ end
65
+ end
66
+
67
+ # Checks whether a value is a hashable object suitable for `to_h` dumping.
68
+ #
69
+ # @param value [Object]
70
+ # @return [Boolean]
71
+ def hashable?(value)
72
+ value.respond_to?(:to_h) && !primitive?(value) && !value.is_a?(Castkit::Attribute)
73
+ end
74
+
75
+ # Determines if a value is a primitive type.
76
+ #
77
+ # @param value [Object]
78
+ # @return [Boolean]
79
+ def primitive?(value)
80
+ case value
81
+ when String, Symbol, Numeric, TrueClass, FalseClass, NilClass, Hash, Array
82
+ true
83
+ else
84
+ false
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end