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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rspec_status +208 -0
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +195 -0
- data/Rakefile +12 -0
- data/castkit.gemspec +41 -0
- data/lib/castkit/attribute.rb +131 -0
- data/lib/castkit/attribute_extensions/access.rb +65 -0
- data/lib/castkit/attribute_extensions/casting.rb +147 -0
- data/lib/castkit/attribute_extensions/error_handling.rb +83 -0
- data/lib/castkit/attribute_extensions/options.rb +124 -0
- data/lib/castkit/attribute_extensions/serialization.rb +89 -0
- data/lib/castkit/attribute_extensions/validation.rb +72 -0
- data/lib/castkit/castkit.rb +44 -0
- data/lib/castkit/configuration.rb +96 -0
- data/lib/castkit/data_object.rb +153 -0
- data/lib/castkit/data_object_extensions/attribute_types.rb +108 -0
- data/lib/castkit/data_object_extensions/attributes.rb +179 -0
- data/lib/castkit/data_object_extensions/config.rb +105 -0
- data/lib/castkit/data_object_extensions/deserialization.rb +110 -0
- data/lib/castkit/default_serializer.rb +99 -0
- data/lib/castkit/error.rb +42 -0
- data/lib/castkit/serializer.rb +92 -0
- data/lib/castkit/validator.rb +37 -0
- data/lib/castkit/validators/numeric_validator.rb +29 -0
- data/lib/castkit/validators/string_validator.rb +34 -0
- data/lib/castkit/validators.rb +4 -0
- data/lib/castkit/version.rb +5 -0
- data/lib/castkit.rb +19 -0
- data/sig/castkit.rbs +4 -0
- metadata +124 -0
@@ -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
|