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,105 @@
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_unknown_keys : (@warn_unknown_keys = value)
74
+ end
75
+
76
+ # Returns a relaxed version of the current class with strict mode off.
77
+ #
78
+ # Useful for tolerant parsing scenarios.
79
+ #
80
+ # @param warn_on_unknown [Boolean]
81
+ # @return [Class] a subclass with relaxed rules
82
+ def relaxed(warn_on_unknown: true)
83
+ klass = Class.new(self)
84
+ klass.strict(false)
85
+ klass.warn_on_unknown(warn_on_unknown)
86
+ klass
87
+ end
88
+ end
89
+
90
+ # Returns the root key for this instance.
91
+ #
92
+ # @return [Symbol]
93
+ def root_key
94
+ self.class.root.to_s.strip.to_sym
95
+ end
96
+
97
+ # Whether a root key is configured for this instance.
98
+ #
99
+ # @return [Boolean]
100
+ def root_key_set?
101
+ !self.class.root.to_s.strip.empty?
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,110 @@
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
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "serializer"
4
+
5
+ module Castkit
6
+ # Default serializer for Castkit::DataObject instances.
7
+ #
8
+ # Serializes attributes based on access rules, nil/blank filtering, and nested structure.
9
+ class DefaultSerializer < Castkit::Serializer
10
+ SKIP_ATTRIBUTE = :__castkit_skip_attribute
11
+
12
+ # @return [Hash{Symbol => Castkit::Attribute}] attributes to serialize
13
+ attr_reader :attributes
14
+
15
+ # @return [Hash] serialization options (root key, ignore_nil, etc.)
16
+ attr_reader :options
17
+
18
+ # Returns the serialized object as a Hash.
19
+ #
20
+ # Includes root wrapping if configured.
21
+ #
22
+ # @return [Hash]
23
+ def call
24
+ result = serialize_attributes
25
+ options[:root] ? { options[:root].to_sym => result } : result
26
+ end
27
+
28
+ private
29
+
30
+ # Initializes the serializer with the target object and context.
31
+ #
32
+ # @param raw [Castkit::DataObject] the object to serialize
33
+ # @param visited [Set, nil] used to detect circular references
34
+ def initialize(raw, visited: nil)
35
+ super
36
+
37
+ @attributes = raw.class.attributes
38
+ @options = {
39
+ root: raw.class.root,
40
+ ignore_nil: raw.class.ignore_nil
41
+ }
42
+ end
43
+
44
+ # Iterates over attributes and serializes each into a result hash.
45
+ #
46
+ # @return [Hash]
47
+ def serialize_attributes
48
+ attributes.each_with_object({}) do |(_, attribute), hash|
49
+ next if attribute.skip_serialization?
50
+
51
+ serialized_value = serialize_attribute(attribute)
52
+ next if serialized_value == SKIP_ATTRIBUTE
53
+
54
+ assign_attribute_key!(hash, attribute, serialized_value)
55
+ end
56
+ end
57
+
58
+ # Process and serialize a given attribute.
59
+ #
60
+ # @param attribute [Castkit::Attribute] The attribute instance.
61
+ # @param [Object]
62
+ def serialize_attribute(attribute)
63
+ value = obj.public_send(attribute.field)
64
+ return SKIP_ATTRIBUTE if value.nil? && (attribute.ignore_nil? || options[:ignore_nil])
65
+
66
+ serialized_value = attribute.dump(value, visited: visited)
67
+ return SKIP_ATTRIBUTE if blank?(serialized_value) && (attribute.ignore_blank? || options[:ignore_blank])
68
+
69
+ serialized_value
70
+ end
71
+
72
+ # Assigns a serialized value into the hash using nested key paths.
73
+ #
74
+ # @param hash [Hash]
75
+ # @param attribute [Castkit::Attribute]
76
+ # @param value [Object]
77
+ # @return [void]
78
+ def assign_attribute_key!(hash, attribute, value)
79
+ key_path = attribute.key_path
80
+ last = key_path.pop
81
+ current = hash
82
+
83
+ key_path.each do |key|
84
+ current[key] ||= {}
85
+ current = current[key]
86
+ end
87
+
88
+ current[last] = value
89
+ end
90
+
91
+ # Determines if a value is blank (nil or empty).
92
+ #
93
+ # @param value [Object, nil]
94
+ # @return [Boolean]
95
+ def blank?(value)
96
+ value.nil? || (value.respond_to?(:empty?) && value&.empty?)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ # Base error class for all Castkit-related exceptions.
5
+ class Error < StandardError
6
+ # @return [Hash, Object, nil] contextual data to aid in debugging
7
+ attr_reader :context
8
+
9
+ # Initializes a Castkit error.
10
+ #
11
+ # @param msg [String] the error message
12
+ # @param context [Object, nil] optional data object or hash for context
13
+ def initialize(msg, context: nil)
14
+ super(msg)
15
+ @context = context
16
+ end
17
+ end
18
+
19
+ # Raised for issues related to Castkit::DataObject initialization or usage.
20
+ class DataObjectError < Error; end
21
+
22
+ # Raised for attribute validation, access, or casting failures.
23
+ class AttributeError < Error
24
+ # Returns the field name related to the error, if available.
25
+ #
26
+ # @return [Symbol]
27
+ def field
28
+ context.is_a?(Hash) ? context[:field] : context || :unknown
29
+ end
30
+
31
+ # Formats the error message with field info if available.
32
+ #
33
+ # @return [String]
34
+ def to_s
35
+ field_info = field ? " (on #{field})" : ""
36
+ "#{super}#{field_info}"
37
+ end
38
+ end
39
+
40
+ # Raised during serialization if an object fails to serialize properly.
41
+ class SerializationError < Error; end
42
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Castkit
6
+ # Abstract base class for defining custom serializers for Castkit::DataObject instances.
7
+ #
8
+ # Handles circular reference detection and provides a consistent `call` API.
9
+ #
10
+ # Subclasses must implement an instance method `#call` that returns a hash-like representation.
11
+ #
12
+ # @example Usage
13
+ # class CustomSerializer < Castkit::Serializer
14
+ # private
15
+ #
16
+ # def call
17
+ # { type: obj.class.name, data: obj.to_h }
18
+ # end
19
+ # end
20
+ #
21
+ # CustomSerializer.call(user_dto)
22
+ class Serializer
23
+ class << self
24
+ # Entrypoint for serializing an object.
25
+ #
26
+ # @param obj [Castkit::DataObject] the object to serialize
27
+ # @param visited [Set, nil] used to track visited object IDs
28
+ # @return [Object] result of custom serialization
29
+ def call(obj, visited: nil)
30
+ new(obj, visited: visited).send(:serialize)
31
+ end
32
+ end
33
+
34
+ # @return [Castkit::DataObject] the object being serialized
35
+ attr_reader :obj
36
+
37
+ protected
38
+
39
+ # Fallback to the default serializer.
40
+ #
41
+ # @return [Hash]
42
+ def serialize_with_default
43
+ Castkit::DefaultSerializer.call(obj, visited: visited)
44
+ end
45
+
46
+ private
47
+
48
+ # @return [Set<Integer>] a set of visited object IDs to detect circular references
49
+ attr_reader :visited
50
+
51
+ # Initializes the serializer instance.
52
+ #
53
+ # @param obj [Castkit::DataObject]
54
+ # @param visited [Set, nil]
55
+ def initialize(obj, visited: nil)
56
+ @obj = obj
57
+ @visited = visited || Set.new
58
+ end
59
+
60
+ # Subclasses must override this method to implement serialization logic.
61
+ #
62
+ # @raise [NotImplementedError]
63
+ # @return [Object]
64
+ def call
65
+ raise NotImplementedError, "#{self.class.name} must implement `#call`"
66
+ end
67
+
68
+ # Wraps the actual serialization logic with circular reference detection.
69
+ #
70
+ # @return [Object]
71
+ # @raise [Castkit::SerializationError] if a circular reference is detected
72
+ def serialize
73
+ check_circular_reference!
74
+ visited << obj.object_id
75
+
76
+ result = call
77
+ visited.delete(obj.object_id)
78
+
79
+ result
80
+ end
81
+
82
+ # Raises if the object has already been visited (circular reference).
83
+ #
84
+ # @raise [Castkit::SerializationError]
85
+ # @return [void]
86
+ def check_circular_reference!
87
+ return unless visited.include?(obj.object_id)
88
+
89
+ raise Castkit::SerializationError, "Circular reference detected for #{obj.class}"
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ # Abstract base class for all attribute validators.
5
+ #
6
+ # Validators ensure that a value conforms to specific rules (e.g., type, format, range).
7
+ # Subclasses must implement the instance method `#call`.
8
+ #
9
+ # @abstract
10
+ class Validator
11
+ class << self
12
+ # Invokes the validator with the given arguments.
13
+ #
14
+ # @param value [Object] the attribute value to validate
15
+ # @param options [Hash] the attribute options (e.g., `min`, `max`, `format`)
16
+ # @param context [Symbol, String, Hash] the attribute name or context for error reporting
17
+ # @return [void]
18
+ # @raise [Castkit::AttributeError] if validation fails
19
+ def call(value, options:, context:)
20
+ new.call(value, options: options, context: context)
21
+ end
22
+ end
23
+
24
+ # Validates the attribute value.
25
+ #
26
+ # @abstract Override in subclasses.
27
+ #
28
+ # @param value [Object] the attribute value to validate
29
+ # @param options [Hash] the attribute options
30
+ # @param context [Symbol, String, Hash] the attribute name or context
31
+ # @return [void]
32
+ # @raise [NotImplementedError] unless implemented in a subclass
33
+ def call(value, options:, context:)
34
+ raise NotImplementedError, "#{self.class.name} must implement `#call`"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validator"
4
+
5
+ module Castkit
6
+ module Validators
7
+ # Validates that a numeric value falls within the allowed range.
8
+ #
9
+ # Supports `:min` and `:max` options to enforce bounds.
10
+ class NumericValidator < Castkit::Validator
11
+ # Validates the numeric value.
12
+ #
13
+ # @param value [Numeric] the value to validate
14
+ # @param options [Hash] validation options (e.g., `min`, `max`)
15
+ # @param context [Symbol, String] the attribute name or key for error messages
16
+ # @raise [Castkit::AttributeError] if the value violates min/max bounds
17
+ # @return [void]
18
+ def call(value, options:, context:)
19
+ if options[:min] && value < options[:min]
20
+ raise Castkit::AttributeError, "#{context} must be >= #{options[:min]}"
21
+ end
22
+
23
+ return unless options[:max] && value > options[:max]
24
+
25
+ raise Castkit::AttributeError, "#{context} must be <= #{options[:max]}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validator"
4
+
5
+ module Castkit
6
+ module Validators
7
+ # Validates that a value is a String and optionally conforms to a format.
8
+ #
9
+ # Supports format validation using a Regexp or a custom Proc.
10
+ class StringValidator < Castkit::Validator
11
+ # Validates the string value.
12
+ #
13
+ # @param value [Object] the value to validate
14
+ # @param options [Hash] validation options (e.g., `format: /regex/` or `format: ->(v) { ... }`)
15
+ # @param context [Symbol, String] the attribute name or key for error messages
16
+ # @raise [Castkit::AttributeError] if value is not a string or fails format validation
17
+ # @return [void]
18
+ def call(value, options:, context:)
19
+ raise Castkit::AttributeError, "#{context} must be a String" unless value.is_a?(String)
20
+
21
+ return unless options[:format]
22
+
23
+ case options[:format]
24
+ when Regexp
25
+ raise Castkit::AttributeError, "#{context} must match format" unless value =~ options[:format]
26
+ when Proc
27
+ raise Castkit::AttributeError, "#{context} failed format validation" unless options[:format].call(value)
28
+ else
29
+ raise Castkit::AttributeError, "#{context} has unsupported format validator: #{options[:format].class}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "validators/numeric_validator"
4
+ require_relative "validators/string_validator"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ VERSION = "0.1.0"
5
+ end
data/lib/castkit.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "castkit/version"
4
+ require_relative "castkit/data_object"
5
+
6
+ # Castkit is a lightweight, type-safe data object system for Ruby.
7
+ #
8
+ # It provides a declarative DSL for defining DTOs with typecasting, validation,
9
+ # access control, serialization, deserialization, and OpenAPI-friendly schema generation.
10
+ #
11
+ # @example Defining a simple data object
12
+ # class UserDto < Castkit::DataObject
13
+ # string :name
14
+ # integer :age, required: false
15
+ # end
16
+ #
17
+ # user = UserDto.new(name: "Alice", age: 30)
18
+ # user.to_h #=> { name: "Alice", age: 30 }
19
+ module Castkit; end
data/sig/castkit.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Castkit
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end