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,72 @@
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
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+
5
+ # Castkit is a lightweight, type-safe data object system for Ruby.
6
+ #
7
+ # It provides a declarative DSL for defining DTOs with typecasting, validation,
8
+ # access control, serialization, deserialization, and OpenAPI-friendly schema generation.
9
+ #
10
+ # @example Defining a simple data object
11
+ # class UserDto < Castkit::DataObject
12
+ # string :name
13
+ # integer :age, required: false
14
+ # end
15
+ #
16
+ # user = UserDto.new(name: "Alice", age: 30)
17
+ # user.to_h #=> { name: "Alice", age: 30 }
18
+ module Castkit
19
+ class << self
20
+ # Yields the global configuration object for customization.
21
+ #
22
+ # @example
23
+ # Castkit.configure do |config|
24
+ # config.enforce_boolean_casting = false
25
+ # end
26
+ #
27
+ # @yieldparam config [Castkit::Configuration]
28
+ # @return [void]
29
+ def configure
30
+ yield(configuration)
31
+ end
32
+
33
+ # Retrieves the global Castkit configuration.
34
+ #
35
+ # @return [Castkit::Configuration] the configuration instance
36
+ def configuration
37
+ @configuration ||= Configuration.new
38
+ end
39
+
40
+ def dataobject?(obj)
41
+ obj.is_a?(Class) && obj.ancestors.include?(Castkit::DataObject)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "validators"
4
+
5
+ module Castkit
6
+ # Configuration container for global Castkit settings.
7
+ #
8
+ # This includes validator registration and enforcement flags for various runtime checks.
9
+ class Configuration
10
+ # Default mapping of primitive types to validators.
11
+ #
12
+ # @return [Hash<Symbol, Class>]
13
+ DEFAULT_VALIDATORS = {
14
+ string: Castkit::Validators::StringValidator,
15
+ integer: Castkit::Validators::NumericValidator,
16
+ float: Castkit::Validators::NumericValidator
17
+ }.freeze
18
+
19
+ # @return [Hash<Symbol, #call>] registered validators by type
20
+ attr_reader :validators
21
+
22
+ # Whether to raise an error if `of:` is missing for array types.
23
+ # @return [Boolean]
24
+ attr_accessor :enforce_array_of_type
25
+
26
+ # Whether to raise an error for unrecognized primitive types.
27
+ # @return [Boolean]
28
+ attr_accessor :enforce_known_primitive_type
29
+
30
+ # Whether to raise an error on invalid boolean coercion.
31
+ # @return [Boolean]
32
+ attr_accessor :enforce_boolean_casting
33
+
34
+ # Whether to raise an error if a union type has no matching candidate.
35
+ # @return [Boolean]
36
+ attr_accessor :enforce_union_match
37
+
38
+ # Whether to raise an error if access mode is not recognized.
39
+ # @return [Boolean]
40
+ attr_accessor :enforce_attribute_access
41
+
42
+ # Whether to raise an error if a prefix is defined without `unwrapped: true`.
43
+ # @return [Boolean]
44
+ attr_accessor :enforce_unwrapped_prefix
45
+
46
+ # Whether to raise an error if an array attribute is missing the `of:` type.
47
+ # @return [Boolean]
48
+ attr_accessor :enforce_array_options
49
+
50
+ # Initializes the configuration with default validators and enforcement settings.
51
+ #
52
+ # @return [void]
53
+ def initialize
54
+ @validators = DEFAULT_VALIDATORS.dup
55
+ @enforce_array_of_type = true
56
+ @enforce_known_primitive_type = true
57
+ @enforce_boolean_casting = true
58
+ @enforce_union_match = true
59
+ @enforce_attribute_access = true
60
+ @enforce_unwrapped_prefix = true
61
+ @enforce_array_options = true
62
+ end
63
+
64
+ # Registers a custom validator for a given type.
65
+ #
66
+ # @param type [Symbol] the type symbol (e.g., :string, :integer)
67
+ # @param validator [#call] a callable object that implements `call(value, options:, context:)`
68
+ # @param override [Boolean] whether to override an existing validator
69
+ # @raise [Castkit::Error] if validator does not respond to `.call`
70
+ # @return [void]
71
+ def register_validator(type, validator, override: false)
72
+ return if @validators.key?(type.to_sym) && !override
73
+
74
+ unless validator.respond_to?(:call)
75
+ raise Castkit::Error, "Validator for `#{type}` must respond to `.call(value, options:, context:)`"
76
+ end
77
+
78
+ @validators[type.to_sym] = validator
79
+ end
80
+
81
+ # Returns the registered validator for the given type.
82
+ #
83
+ # @param type [Symbol]
84
+ # @return [#call, nil]
85
+ def validator_for(type)
86
+ validators[type.to_sym]
87
+ end
88
+
89
+ # Resets all validators to their default mappings.
90
+ #
91
+ # @return [void]
92
+ def reset_validators!
93
+ @validators = DEFAULT_VALIDATORS.dup
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "error"
5
+ require_relative "attribute"
6
+ require_relative "default_serializer"
7
+ require_relative "data_object_extensions/config"
8
+ require_relative "data_object_extensions/attributes"
9
+ require_relative "data_object_extensions/attribute_types"
10
+ require_relative "data_object_extensions/deserialization"
11
+
12
+ module Castkit
13
+ # Base class for defining declarative, typed data transfer objects (DTOs).
14
+ #
15
+ # Includes typecasting, validation, access control, serialization, deserialization,
16
+ # and support for custom serializers.
17
+ #
18
+ # @example Defining a DTO
19
+ # class UserDto < Castkit::DataObject
20
+ # string :name
21
+ # integer :age, required: false
22
+ # end
23
+ #
24
+ # @example Instantiating and serializing
25
+ # user = UserDto.new(name: "Alice", age: 30)
26
+ # user.to_json #=> '{"name":"Alice","age":30}'
27
+ class DataObject
28
+ extend Castkit::DataObjectExtensions::Attributes
29
+ extend Castkit::DataObjectExtensions::AttributeTypes
30
+
31
+ include Castkit::DataObjectExtensions::Config
32
+ include Castkit::DataObjectExtensions::Deserialization
33
+
34
+ class << self
35
+ # Gets or sets the serializer class to use for instances of this object.
36
+ #
37
+ # @param value [Class<Castkit::Serializer>, nil]
38
+ # @return [Class<Castkit::Serializer>, nil]
39
+ # @raise [ArgumentError] if value does not inherit from Castkit::Serializer
40
+ def serializer(value = nil)
41
+ if value
42
+ raise ArgumentError, "Serializer must inherit from Castkit::Serializer" unless value < Castkit::Serializer
43
+
44
+ @serializer = value
45
+ else
46
+ @serializer
47
+ end
48
+ end
49
+
50
+ # Casts a value into an instance of this class.
51
+ #
52
+ # @param obj [self, Hash]
53
+ # @return [self]
54
+ # @raise [Castkit::DataObjectError] if obj is not castable
55
+ def cast(obj)
56
+ case obj
57
+ when self
58
+ obj
59
+ when Hash
60
+ from_h(obj)
61
+ else
62
+ raise Castkit::DataObjectError, "Can't cast #{obj.class} to #{name}"
63
+ end
64
+ end
65
+
66
+ # Converts an object to its JSON representation.
67
+ #
68
+ # @param obj [Castkit::DataObject]
69
+ # @return [String]
70
+ def dump(obj)
71
+ obj.to_json
72
+ end
73
+ end
74
+
75
+ # Initializes the DTO from a hash of attributes.
76
+ #
77
+ # @param fields [Hash] raw input hash
78
+ # @raise [Castkit::DataObjectError] if strict mode is enabled and unknown keys are present
79
+ def initialize(fields = {})
80
+ root = self.class.root
81
+ fields = fields[root] if root && fields.key?(root)
82
+ fields = unwrap_prefixed_fields!(fields)
83
+
84
+ validate_keys!(fields)
85
+ deserialize_attributes!(fields)
86
+ end
87
+
88
+ # Serializes the DTO to a Ruby hash.
89
+ #
90
+ # @param visited [Set, nil] used to track circular references
91
+ # @return [Hash]
92
+ def to_hash(visited: nil)
93
+ serializer = self.class.serializer || Castkit::DefaultSerializer
94
+ serializer.call(self, visited: visited)
95
+ end
96
+
97
+ # Serializes the DTO to a JSON string.
98
+ #
99
+ # @param options [Hash, nil] options passed to `JSON.generate`
100
+ # @return [String]
101
+ def to_json(options = nil)
102
+ JSON.generate(serializer.call(self), options)
103
+ end
104
+
105
+ # @!method to_h
106
+ # Alias for {#to_hash}
107
+ #
108
+ # @!method serialize
109
+ # Alias for {#to_hash}
110
+ alias to_h to_hash
111
+ alias serialize to_hash
112
+
113
+ private
114
+
115
+ # Validates that the input only contains known keys unless configured otherwise.
116
+ #
117
+ # @param data [Hash]
118
+ # @raise [Castkit::DataObjectError] in strict mode if unknown keys are present
119
+ # @return [void]
120
+ def validate_keys!(data)
121
+ valid_keys = self.class.attributes.flat_map do |_, attr|
122
+ [attr.key] + attr.options[:aliases]
123
+ end.map(&:to_sym).uniq
124
+
125
+ unknown_keys = data.keys.map(&:to_sym) - valid_keys
126
+ return if unknown_keys.empty?
127
+
128
+ handle_unknown_keys!(unknown_keys)
129
+ end
130
+
131
+ # Handles unknown keys found during initialization.
132
+ #
133
+ # Behavior depends on the class-level configuration:
134
+ # - Raises a `Castkit::DataObjectError` if strict mode is enabled.
135
+ # - Logs a warning if `warn_on_unknown` is enabled.
136
+ #
137
+ # @param unknown_keys [Array<Symbol>] list of unknown keys not declared as attributes or aliases
138
+ # @raise [Castkit::DataObjectError] if strict mode is active
139
+ # @return [void]
140
+ def handle_unknown_keys!(unknown_keys)
141
+ raise Castkit::DataObjectError, "Unknown attribute(s): #{unknown_keys.join(", ")}" if self.class.strict
142
+
143
+ warn "⚠️ [Castkit] Unknown attribute(s) ignored: #{unknown_keys.join(", ")}" if self.class.warn_on_unknown
144
+ end
145
+
146
+ # Returns the serializer instance or default for this object.
147
+ #
148
+ # @return [Class<Castkit::Serializer>]
149
+ def serializer
150
+ @serializer ||= self.class.serializer || Castkit::DefaultSerializer
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module DataObjectExtensions
5
+ # Provides DSL methods to define typed attributes in a Castkit::DataObject.
6
+ #
7
+ # These helpers are shortcuts for calling `attribute` with a specific type.
8
+ module AttributeTypes
9
+ # Defines a string attribute.
10
+ #
11
+ # @param field [Symbol]
12
+ # @param options [Hash]
13
+ def string(field, **options)
14
+ attribute(field, :string, **options)
15
+ end
16
+
17
+ # Defines an integer attribute.
18
+ #
19
+ # @param field [Symbol]
20
+ # @param options [Hash]
21
+ def integer(field, **options)
22
+ attribute(field, :integer, **options)
23
+ end
24
+
25
+ # Defines a boolean attribute.
26
+ #
27
+ # @param field [Symbol]
28
+ # @param options [Hash]
29
+ def boolean(field, **options)
30
+ attribute(field, :boolean, **options)
31
+ end
32
+
33
+ # Defines a float attribute.
34
+ #
35
+ # @param field [Symbol]
36
+ # @param options [Hash]
37
+ def float(field, **options)
38
+ attribute(field, :float, **options)
39
+ end
40
+
41
+ # Defines a date attribute.
42
+ #
43
+ # @param field [Symbol]
44
+ # @param options [Hash]
45
+ def date(field, **options)
46
+ attribute(field, :date, **options)
47
+ end
48
+
49
+ # Defines a datetime attribute.
50
+ #
51
+ # @param field [Symbol]
52
+ # @param options [Hash]
53
+ def datetime(field, **options)
54
+ attribute(field, :datetime, **options)
55
+ end
56
+
57
+ # Defines an array attribute.
58
+ #
59
+ # @param field [Symbol]
60
+ # @param options [Hash]
61
+ def array(field, **options)
62
+ attribute(field, :array, **options)
63
+ end
64
+
65
+ # Defines a hash attribute.
66
+ #
67
+ # @param field [Symbol]
68
+ # @param options [Hash]
69
+ def hash(field, **options)
70
+ attribute(field, :hash, **options)
71
+ end
72
+
73
+ # Defines a nested Castkit::DataObject attribute.
74
+ #
75
+ # @param field [Symbol]
76
+ # @param type [Class<Castkit::DataObject>]
77
+ # @param options [Hash]
78
+ # @raise [Castkit::AttributeError] if type is not a subclass of Castkit::DataObject
79
+ def dataobject(field, type, **options)
80
+ unless type < Castkit::DataObject
81
+ raise Castkit::AttributeError, "Data objects must extend from Castkit::DataObject"
82
+ end
83
+
84
+ attribute(field, type, **options)
85
+ end
86
+
87
+ # Defines an unwrapped nested Castkit::DataObject attribute.
88
+ #
89
+ # All keys from this object will be flattened with an optional prefix.
90
+ #
91
+ # @param field [Symbol]
92
+ # @param type [Class<Castkit::DataObject>]
93
+ # @param options [Hash]
94
+ def unwrapped(field, type, **options)
95
+ attribute(field, type, **options, unwrapped: true)
96
+ end
97
+
98
+ # Alias for `array`
99
+ alias collection array
100
+
101
+ # Alias for `dataobject`
102
+ alias object dataobject
103
+
104
+ # Alias for `dataobject`
105
+ alias dto dataobject
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module DataObjectExtensions
5
+ # Provides DSL and implementation for declaring attributes within a Castkit::DataObject.
6
+ #
7
+ # Includes support for regular, composite, transient, readonly/writeonly, and grouped attribute definitions.
8
+ module Attributes
9
+ # Declares an attribute with the given type and options.
10
+ #
11
+ # If `:transient` is true, defines only standard accessors and skips serialization logic.
12
+ #
13
+ # @param field [Symbol]
14
+ # @param type [Symbol, Class]
15
+ # @param options [Hash]
16
+ # @return [void]
17
+ # @raise [Castkit::DataObjectError] if the attribute is already defined
18
+ def attribute(field, type, **options)
19
+ field = field.to_sym
20
+ raise Castkit::DataObjectError, "Attribute '#{field}' already defined" if attributes.key?(field)
21
+
22
+ options = build_options(options)
23
+ return define_attribute(field, type, **options) unless options[:transient]
24
+
25
+ attr_accessor field
26
+ end
27
+
28
+ # Declares a computed (composite) attribute.
29
+ #
30
+ # The provided block defines the read behavior.
31
+ #
32
+ # @param field [Symbol]
33
+ # @param type [Symbol, Class]
34
+ # @param options [Hash]
35
+ # @yieldreturn [Object] evaluated composite value
36
+ def composite(field, type, **options, &block)
37
+ attribute(field, type, **options, composite: true)
38
+ define_method(field, &block)
39
+ end
40
+
41
+ # Declares a group of transient attributes within the given block.
42
+ #
43
+ # These attributes are not serialized or included in `to_h`.
44
+ #
45
+ # @yield defines one or more transient attributes via `attribute`
46
+ # @return [void]
47
+ def transient(&block)
48
+ @__transient_context = true
49
+ instance_eval(&block)
50
+ ensure
51
+ @__transient_context = nil
52
+ end
53
+
54
+ # Declares a group of readonly attributes within the given block.
55
+ #
56
+ # @param options [Hash] shared options for attributes inside the block
57
+ # @yield defines attributes with `access: [:read]`
58
+ # @return [void]
59
+ def readonly(**options, &block)
60
+ with_access([:read], options, &block)
61
+ end
62
+
63
+ # Declares a group of writeonly attributes within the given block.
64
+ #
65
+ # @param options [Hash] shared options for attributes inside the block
66
+ # @yield defines attributes with `access: [:write]`
67
+ # @return [void]
68
+ def writeonly(**options, &block)
69
+ with_access([:write], options, &block)
70
+ end
71
+
72
+ # Declares a group of required attributes within the given block.
73
+ #
74
+ # @param options [Hash] shared options for attributes inside the block
75
+ # @yield defines attributes with `required: true`
76
+ # @return [void]
77
+ def required(**options, &block)
78
+ with_required(true, options, &block)
79
+ end
80
+
81
+ # Declares a group of optional attributes within the given block.
82
+ #
83
+ # @param options [Hash] shared options for attributes inside the block
84
+ # @yield defines attributes with `required: false`
85
+ # @return [void]
86
+ def optional(**options, &block)
87
+ with_required(false, options, &block)
88
+ end
89
+
90
+ # Returns all declared non-transient attributes.
91
+ #
92
+ # @return [Hash{Symbol => Castkit::Attribute}]
93
+ def attributes
94
+ @attributes ||= {}
95
+ end
96
+
97
+ # Alias for `composite`
98
+ #
99
+ # @see #composite
100
+ alias property composite
101
+
102
+ private
103
+
104
+ # Defines a full attribute, including accessor methods and type logic.
105
+ #
106
+ # @param field [Symbol]
107
+ # @param type [Symbol, Class]
108
+ # @param options [Hash]
109
+ def define_attribute(field, type, **options)
110
+ attribute = Castkit::Attribute.new(field, type, **options)
111
+ attributes[field] = attribute
112
+
113
+ if attribute.full_access?
114
+ attr_reader field
115
+
116
+ define_typed_writer(field, attribute)
117
+ elsif attribute.writeable?
118
+ define_typed_writer(field, attribute)
119
+ elsif attribute.readable?
120
+ attr_reader field
121
+ end
122
+ end
123
+
124
+ # Defines a type-aware writer method for the attribute.
125
+ #
126
+ # @param field [Symbol]
127
+ # @param attribute [Castkit::Attribute]
128
+ def define_typed_writer(field, attribute)
129
+ define_method("#{field}=") do |value|
130
+ casted = attribute.load(value, context: field)
131
+ instance_variable_set("@#{field}", casted)
132
+ end
133
+ end
134
+
135
+ # Applies scoped access control to all attributes declared in the given block.
136
+ #
137
+ # @param access [Array<Symbol>] e.g., [:read] or [:write]
138
+ # @param options [Hash]
139
+ # @yield the block containing one or more `attribute` calls
140
+ def with_access(access, options = {}, &block)
141
+ @__access_context = access
142
+ @__block_options = options
143
+ instance_eval(&block)
144
+ ensure
145
+ @__access_context = nil
146
+ @__block_options = nil
147
+ end
148
+
149
+ # Applies scoped required/optional flag to all attributes declared in the given block.
150
+ #
151
+ # @param flag [Boolean]
152
+ # @param options [Hash]
153
+ # @yield the block containing one or more `attribute` calls
154
+ def with_required(flag, options = {}, &block)
155
+ @__required_context = flag
156
+ @__block_options = options
157
+ instance_eval(&block)
158
+ ensure
159
+ @__required_context = nil
160
+ @__block_options = nil
161
+ end
162
+
163
+ # Builds effective options for the current attribute definition.
164
+ #
165
+ # Merges scoped flags like `required`, `access`, and `transient` if present.
166
+ #
167
+ # @param options [Hash]
168
+ # @return [Hash]
169
+ def build_options(options)
170
+ base = @__block_options || {}
171
+ base = base.merge(required: @__required_context) unless @__required_context.nil?
172
+ base = base.merge(access: @__access_context) unless @__access_context.nil?
173
+ base = base.merge(transient: true) if @__transient_context
174
+
175
+ base.merge(options)
176
+ end
177
+ end
178
+ end
179
+ end