castkit 0.1.2 → 0.2.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +196 -219
  3. data/CHANGELOG.md +42 -0
  4. data/README.md +469 -84
  5. data/lib/castkit/attribute.rb +6 -24
  6. data/lib/castkit/castkit.rb +58 -10
  7. data/lib/castkit/configuration.rb +94 -47
  8. data/lib/castkit/contract/data_object.rb +62 -0
  9. data/lib/castkit/contract/generic.rb +168 -0
  10. data/lib/castkit/contract/result.rb +74 -0
  11. data/lib/castkit/contract/validator.rb +248 -0
  12. data/lib/castkit/contract.rb +67 -0
  13. data/lib/castkit/{data_object_extensions → core}/attribute_types.rb +21 -7
  14. data/lib/castkit/{data_object_extensions → core}/attributes.rb +8 -3
  15. data/lib/castkit/core/config.rb +74 -0
  16. data/lib/castkit/core/registerable.rb +59 -0
  17. data/lib/castkit/data_object.rb +45 -60
  18. data/lib/castkit/default_serializer.rb +85 -54
  19. data/lib/castkit/error.rb +15 -3
  20. data/lib/castkit/ext/attribute/access.rb +67 -0
  21. data/lib/castkit/ext/attribute/error_handling.rb +63 -0
  22. data/lib/castkit/ext/attribute/options.rb +142 -0
  23. data/lib/castkit/ext/attribute/validation.rb +85 -0
  24. data/lib/castkit/ext/data_object/contract.rb +96 -0
  25. data/lib/castkit/ext/data_object/deserialization.rb +167 -0
  26. data/lib/castkit/ext/data_object/serialization.rb +61 -0
  27. data/lib/castkit/inflector.rb +47 -0
  28. data/lib/castkit/types/boolean.rb +43 -0
  29. data/lib/castkit/types/collection.rb +24 -0
  30. data/lib/castkit/types/date.rb +34 -0
  31. data/lib/castkit/types/date_time.rb +34 -0
  32. data/lib/castkit/types/float.rb +46 -0
  33. data/lib/castkit/types/generic.rb +123 -0
  34. data/lib/castkit/types/integer.rb +46 -0
  35. data/lib/castkit/types/string.rb +44 -0
  36. data/lib/castkit/types.rb +15 -0
  37. data/lib/castkit/validators/base_validator.rb +39 -0
  38. data/lib/castkit/validators/numeric_validator.rb +2 -2
  39. data/lib/castkit/validators/string_validator.rb +3 -3
  40. data/lib/castkit/version.rb +1 -1
  41. data/lib/castkit.rb +2 -0
  42. metadata +29 -13
  43. data/lib/castkit/attribute_extensions/access.rb +0 -65
  44. data/lib/castkit/attribute_extensions/casting.rb +0 -147
  45. data/lib/castkit/attribute_extensions/error_handling.rb +0 -83
  46. data/lib/castkit/attribute_extensions/options.rb +0 -131
  47. data/lib/castkit/attribute_extensions/serialization.rb +0 -89
  48. data/lib/castkit/attribute_extensions/validation.rb +0 -72
  49. data/lib/castkit/data_object_extensions/config.rb +0 -113
  50. data/lib/castkit/data_object_extensions/deserialization.rb +0 -110
  51. data/lib/castkit/validators.rb +0 -4
@@ -1,131 +0,0 @@
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 a collection of Castkit::DataObjects.
101
- #
102
- # @return [Boolean]
103
- def dataobject_collection?
104
- type == :array && Castkit.dataobject?(options[:of])
105
- end
106
-
107
- # Whether the attribute is considered composite (not exposed in serialized output).
108
- #
109
- # @return [Boolean]
110
- def composite?
111
- options[:composite]
112
- end
113
-
114
- # Whether the attribute is unwrapped into the parent object.
115
- #
116
- # Only applies to Castkit::DataObject types.
117
- #
118
- # @return [Boolean]
119
- def unwrapped?
120
- dataobject? && options[:unwrapped]
121
- end
122
-
123
- # Returns the prefix used for unwrapped attributes.
124
- #
125
- # @return [String, nil]
126
- def prefix
127
- options[:prefix]
128
- end
129
- end
130
- end
131
- end
@@ -1,89 +0,0 @@
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, nil] the value to serialize
14
- # @param visited [Set, nil] used for circular reference detection
15
- # @return [Object, nil] 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, nil] the element to dump
52
- # @param visited [Set, nil]
53
- # @return [Object, nil]
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, nil]
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, nil]
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
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "error_handling"
4
- require_relative "options"
5
-
6
- module Castkit
7
- module AttributeExtensions
8
- # Provides validation logic for attribute configuration.
9
- #
10
- # These checks are typically performed at attribute initialization to catch misconfigurations early.
11
- module Validation
12
- include Castkit::AttributeExtensions::ErrorHandling
13
-
14
- private
15
-
16
- # Runs all validation checks on the attribute definition.
17
- #
18
- # This includes:
19
- # - Custom validator integrity
20
- # - Access mode validity
21
- # - Unwrapped prefix usage
22
- # - Array `of:` type presence
23
- #
24
- # @return [void]
25
- def validate!
26
- validate_custom_validator!
27
- validate_access!
28
- validate_unwrapped_options!
29
- validate_array_options!
30
- end
31
-
32
- # Validates the presence and interface of a custom validator.
33
- #
34
- # @return [void]
35
- # @raise [Castkit::AttributeError] if the validator is not callable
36
- def validate_custom_validator!
37
- return unless options[:validator]
38
- return if options[:validator].respond_to?(:call)
39
-
40
- raise_error!("Custom validator for `#{field}` must respond to `.call`")
41
- end
42
-
43
- # Validates that each declared access mode is valid.
44
- #
45
- # @return [void]
46
- # @raise [Castkit::AttributeError] if any access mode is invalid and enforcement is enabled
47
- def validate_access!
48
- access.each do |mode|
49
- next if Castkit::AttributeExtensions::Options::DEFAULT_OPTIONS[:access].include?(mode)
50
-
51
- handle_error(:access, mode: mode, context: to_h)
52
- end
53
- end
54
-
55
- # Ensures prefix is only used with unwrapped attributes.
56
- #
57
- # @return [void]
58
- # @raise [Castkit::AttributeError] if prefix is used without `unwrapped: true`
59
- def validate_unwrapped_options!
60
- handle_error(:unwrapped, context: to_h) if prefix && !unwrapped?
61
- end
62
-
63
- # Ensures `of:` is provided for array-typed attributes.
64
- #
65
- # @return [void]
66
- # @raise [Castkit::AttributeError] if `of:` is missing for `type: :array`
67
- def validate_array_options!
68
- handle_error(:array_options, context: to_h) if type == :array && options[:of].nil?
69
- end
70
- end
71
- end
72
- end
@@ -1,113 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Castkit
4
- module DataObjectExtensions
5
- # Provides per-class configuration for a Castkit::DataObject,
6
- # including root key handling, strict mode, and unknown key behavior.
7
- module Config
8
- # Automatically extends class-level methods when included.
9
- #
10
- # @param base [Class]
11
- def self.included(base)
12
- base.extend(ClassMethods)
13
- end
14
-
15
- # Class-level configuration methods.
16
- module ClassMethods
17
- # Sets or retrieves the root key to wrap the object under during (de)serialization.
18
- #
19
- # @param value [String, Symbol, nil] optional root key
20
- # @return [Symbol, nil]
21
- def root(value = nil)
22
- @root = value.to_s.strip.to_sym if value
23
- @root
24
- end
25
-
26
- # Sets or retrieves whether to skip `nil` values in output.
27
- #
28
- # @param value [Boolean, nil]
29
- # @return [Boolean, nil]
30
- def ignore_nil(value = nil)
31
- value.nil? ? @ignore_nil : (@ignore_nil = value)
32
- end
33
-
34
- # Sets or retrieves whether to skip blank values (`[]`, `{}`, `""`, etc.) in output.
35
- #
36
- # Defaults to true unless explicitly set to false.
37
- #
38
- # @param value [Boolean, nil]
39
- # @return [Boolean]
40
- def ignore_blank(value = nil)
41
- @ignore_blank = value.nil? || value
42
- end
43
-
44
- # Sets or retrieves strict mode behavior.
45
- #
46
- # In strict mode, unknown keys during deserialization raise errors.
47
- #
48
- # @param value [Boolean, nil]
49
- # @return [Boolean]
50
- def strict(value = nil)
51
- if value.nil?
52
- @strict.nil? || @strict
53
- else
54
- @strict = value
55
- end
56
- end
57
-
58
- # Enables or disables ignoring unknown keys during deserialization.
59
- #
60
- # This is the inverse of `strict`.
61
- #
62
- # @param value [Boolean]
63
- # @return [void]
64
- def ignore_unknown(value = nil)
65
- @strict = !value
66
- end
67
-
68
- # Sets or retrieves whether to emit warnings when unknown keys are encountered.
69
- #
70
- # @param value [Boolean, nil]
71
- # @return [Boolean, nil]
72
- def warn_on_unknown(value = nil)
73
- value.nil? ? @warn_on_unknown : (@warn_on_unknown = value)
74
- end
75
-
76
- # Sets or retrieves whether to allow unknown keys when they are encountered.
77
- #
78
- # @param value [Boolean, nil]
79
- # @return [Boolean, nil]
80
- def allow_unknown(value = nil)
81
- value.nil? ? @allow_unknown : (@allow_unknown = value)
82
- end
83
-
84
- # Returns a relaxed version of the current class with strict mode off.
85
- #
86
- # Useful for tolerant parsing scenarios.
87
- #
88
- # @param warn_on_unknown [Boolean]
89
- # @return [Class] a subclass with relaxed rules
90
- def relaxed(warn_on_unknown: true)
91
- klass = Class.new(self)
92
- klass.strict(false)
93
- klass.warn_on_unknown(warn_on_unknown)
94
- klass
95
- end
96
- end
97
-
98
- # Returns the root key for this instance.
99
- #
100
- # @return [Symbol]
101
- def root_key
102
- self.class.root.to_s.strip.to_sym
103
- end
104
-
105
- # Whether a root key is configured for this instance.
106
- #
107
- # @return [Boolean]
108
- def root_key_set?
109
- !self.class.root.to_s.strip.empty?
110
- end
111
- end
112
- end
113
- end
@@ -1,110 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Castkit
4
- module DataObjectExtensions
5
- # Adds deserialization support for Castkit::DataObject instances.
6
- #
7
- # Handles attribute loading, alias resolution, and unwrapped field extraction.
8
- module Deserialization
9
- # Hooks in class methods like `.from_hash` when included.
10
- #
11
- # @param base [Class]
12
- def self.included(base)
13
- base.extend(ClassMethods)
14
- end
15
-
16
- # Class-level deserialization helpers.
17
- module ClassMethods
18
- # Builds a new instance from a Hash, symbolizing keys as needed.
19
- #
20
- # @param hash [Hash]
21
- # @return [Castkit::DataObject]
22
- def from_hash(hash)
23
- hash = hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
24
- new(hash)
25
- end
26
-
27
- # @!method from_h(hash)
28
- # Alias for {.from_hash}
29
- #
30
- # @!method creator(hash)
31
- # Alias for {.from_hash}
32
- alias from_h from_hash
33
- alias creator from_hash
34
- end
35
-
36
- private
37
-
38
- # Loads attribute values from the given hash.
39
- #
40
- # Respects access control (e.g., `writeable?`) and uses `.load` for casting/validation.
41
- #
42
- # @param data [Hash]
43
- # @return [void]
44
- def deserialize_attributes!(data)
45
- self.class.attributes.each do |field, attribute|
46
- next if attribute.skip_deserialization?
47
-
48
- value = fetch_attribute_key(data, attribute)
49
- value = attribute.load(value, context: field)
50
-
51
- instance_variable_set("@#{field}", value)
52
- end
53
- end
54
-
55
- # Fetches the best matching value from the hash using attribute key and aliases.
56
- #
57
- # @param data [Hash]
58
- # @param attribute [Castkit::Attribute]
59
- # @return [Object]
60
- def fetch_attribute_key(data, attribute)
61
- attribute.key_path(with_aliases: true).each do |path|
62
- value = path.reduce(data) { |memo, key| memo.is_a?(Hash) ? memo[key] : nil }
63
- return value unless value.nil?
64
- end
65
-
66
- nil
67
- end
68
-
69
- # Extracts prefixed fields for unwrapped attributes and groups them under the original field key.
70
- #
71
- # @param data [Hash]
72
- # @return [Hash] modified input hash with unwrapped values nested under their base field
73
- def unwrap_prefixed_fields!(data)
74
- self.class.attributes.each_value do |attribute|
75
- next unless attribute.unwrapped?
76
-
77
- unwrapped, keys_to_remove = unwrap_prefixed_values(data, attribute)
78
- next if unwrapped.empty?
79
-
80
- data[attribute.field] = unwrapped
81
- keys_to_remove.each { |k| data.delete(k) }
82
- end
83
-
84
- data
85
- end
86
-
87
- # Returns the prefixed key-value pairs for a given unwrapped attribute.
88
- #
89
- # @param data [Hash]
90
- # @param attribute [Castkit::Attribute]
91
- # @return [Array<Hash, Array<Symbol>>] extracted key-value pairs and keys to delete
92
- def unwrap_prefixed_values(data, attribute)
93
- prefix = attribute.prefix.to_s
94
- unwrapped_data = {}
95
- keys_to_remove = []
96
-
97
- data.each do |k, v|
98
- k_str = k.to_s
99
- next unless k_str.start_with?(prefix)
100
-
101
- stripped = k_str.sub(prefix, "").to_sym
102
- unwrapped_data[stripped] = v
103
- keys_to_remove << k
104
- end
105
-
106
- [unwrapped_data, keys_to_remove]
107
- end
108
- end
109
- end
110
- end
@@ -1,4 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "validators/numeric_validator"
4
- require_relative "validators/string_validator"