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.
- checksums.yaml +4 -4
- data/.rspec_status +196 -219
- data/CHANGELOG.md +42 -0
- data/README.md +469 -84
- data/lib/castkit/attribute.rb +6 -24
- data/lib/castkit/castkit.rb +58 -10
- data/lib/castkit/configuration.rb +94 -47
- data/lib/castkit/contract/data_object.rb +62 -0
- data/lib/castkit/contract/generic.rb +168 -0
- data/lib/castkit/contract/result.rb +74 -0
- data/lib/castkit/contract/validator.rb +248 -0
- data/lib/castkit/contract.rb +67 -0
- data/lib/castkit/{data_object_extensions → core}/attribute_types.rb +21 -7
- data/lib/castkit/{data_object_extensions → core}/attributes.rb +8 -3
- data/lib/castkit/core/config.rb +74 -0
- data/lib/castkit/core/registerable.rb +59 -0
- data/lib/castkit/data_object.rb +45 -60
- data/lib/castkit/default_serializer.rb +85 -54
- data/lib/castkit/error.rb +15 -3
- data/lib/castkit/ext/attribute/access.rb +67 -0
- data/lib/castkit/ext/attribute/error_handling.rb +63 -0
- data/lib/castkit/ext/attribute/options.rb +142 -0
- data/lib/castkit/ext/attribute/validation.rb +85 -0
- data/lib/castkit/ext/data_object/contract.rb +96 -0
- data/lib/castkit/ext/data_object/deserialization.rb +167 -0
- data/lib/castkit/ext/data_object/serialization.rb +61 -0
- data/lib/castkit/inflector.rb +47 -0
- data/lib/castkit/types/boolean.rb +43 -0
- data/lib/castkit/types/collection.rb +24 -0
- data/lib/castkit/types/date.rb +34 -0
- data/lib/castkit/types/date_time.rb +34 -0
- data/lib/castkit/types/float.rb +46 -0
- data/lib/castkit/types/generic.rb +123 -0
- data/lib/castkit/types/integer.rb +46 -0
- data/lib/castkit/types/string.rb +44 -0
- data/lib/castkit/types.rb +15 -0
- data/lib/castkit/validators/base_validator.rb +39 -0
- data/lib/castkit/validators/numeric_validator.rb +2 -2
- data/lib/castkit/validators/string_validator.rb +3 -3
- data/lib/castkit/version.rb +1 -1
- data/lib/castkit.rb +2 -0
- metadata +29 -13
- data/lib/castkit/attribute_extensions/access.rb +0 -65
- data/lib/castkit/attribute_extensions/casting.rb +0 -147
- data/lib/castkit/attribute_extensions/error_handling.rb +0 -83
- data/lib/castkit/attribute_extensions/options.rb +0 -131
- data/lib/castkit/attribute_extensions/serialization.rb +0 -89
- data/lib/castkit/attribute_extensions/validation.rb +0 -72
- data/lib/castkit/data_object_extensions/config.rb +0 -113
- data/lib/castkit/data_object_extensions/deserialization.rb +0 -110
- data/lib/castkit/validators.rb +0 -4
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../contract"
|
4
|
+
|
5
|
+
module Castkit
|
6
|
+
module Ext
|
7
|
+
module DataObject
|
8
|
+
# Extension module that adds contract support to Castkit::DataObject classes.
|
9
|
+
#
|
10
|
+
# This allows any DataObject to be:
|
11
|
+
# - Converted into a contract definition (via `.to_contract`)
|
12
|
+
# - Validated against its contract (via `.validate` and `.validate!`)
|
13
|
+
# - Reconstructed from a contract class (via `.from_contract`)
|
14
|
+
#
|
15
|
+
# Example:
|
16
|
+
#
|
17
|
+
# class UserDto < Castkit::DataObject
|
18
|
+
# string :id
|
19
|
+
# string :email
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# contract = UserDto.to_contract
|
23
|
+
# result = UserDto.validate(id: "abc")
|
24
|
+
#
|
25
|
+
# UserDto.from_contract(contract) # => builds an equivalent DataObject class
|
26
|
+
#
|
27
|
+
# This module is automatically extended by Castkit::DataObject and is not intended
|
28
|
+
# to be included manually.
|
29
|
+
module Contract
|
30
|
+
# Returns the associated Castkit::Contract for this DataObject.
|
31
|
+
#
|
32
|
+
# Memoizes the contract once it's built. Uses `to_contract` internally.
|
33
|
+
#
|
34
|
+
# @return [Class<Castkit::Contract::Definition>]
|
35
|
+
def contract
|
36
|
+
@contract ||= to_contract
|
37
|
+
end
|
38
|
+
|
39
|
+
# Converts the current DataObject into a Castkit::Contract subclass.
|
40
|
+
#
|
41
|
+
# If the contract has already been defined, returns the existing definition.
|
42
|
+
# Otherwise, generates and registers a new contract class under Castkit::Contracts.
|
43
|
+
#
|
44
|
+
# @param as [String, Symbol, nil] Optional name for the contract.
|
45
|
+
# If omitted, inferred from the DataObject name.
|
46
|
+
#
|
47
|
+
# @return [Class<Castkit::Contract::Definition>] the generated or existing contract
|
48
|
+
def to_contract(as: nil)
|
49
|
+
Castkit::Contract.from_dataobject(self, as: as)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Constructs a new Castkit::DataObject class from a given contract.
|
53
|
+
#
|
54
|
+
# This method is the inverse of `.to_contract` and provides a way to
|
55
|
+
# generate a DataObject from an existing contract definition.
|
56
|
+
#
|
57
|
+
# @example
|
58
|
+
# UserContract = Castkit::Contract.build(:user) do
|
59
|
+
# string :id
|
60
|
+
# string :email
|
61
|
+
# end
|
62
|
+
#
|
63
|
+
# UserDto = Castkit::DataObject.from_contract(UserContract)
|
64
|
+
# dto = UserDto.new(id: "abc", email: "a@example.com")
|
65
|
+
#
|
66
|
+
# @param contract [Class<Castkit::Contract::Generic>] the contract to convert
|
67
|
+
# @return [Class<Castkit::DataObject>] a new anonymous DataObject class
|
68
|
+
|
69
|
+
def from_contract(contract)
|
70
|
+
Class.new(Castkit::DataObject).tap do |klass|
|
71
|
+
contract.attributes.each_value do |attr|
|
72
|
+
klass.attribute(attr.field, attr.type, **attr.options)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Validates input data using the contract associated with this DataObject.
|
78
|
+
#
|
79
|
+
# @param data [Hash] The input to validate
|
80
|
+
# @return [Castkit::Contract::Result] the result of validation
|
81
|
+
def validate(data)
|
82
|
+
contract.validate(data)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Validates input data and raises if validation fails.
|
86
|
+
#
|
87
|
+
# @param data [Hash] The input to validate
|
88
|
+
# @raise [Castkit::ContractError] if validation fails
|
89
|
+
# @return [void]
|
90
|
+
def validate!(data)
|
91
|
+
contract.validate!(data)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,167 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castkit
|
4
|
+
module Ext
|
5
|
+
module DataObject
|
6
|
+
# Adds deserialization support for Castkit::DataObject instances.
|
7
|
+
#
|
8
|
+
# Handles attribute loading, alias resolution, default fallback, nested DataObject casting,
|
9
|
+
# unwrapped field extraction, and optional attribute enforcement.
|
10
|
+
module Deserialization
|
11
|
+
# Hooks in class methods like `.from_hash` when included.
|
12
|
+
#
|
13
|
+
# @param base [Class] the class including this module
|
14
|
+
def self.included(base)
|
15
|
+
base.extend(ClassMethods)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Class-level deserialization helpers for Castkit::DataObject.
|
19
|
+
module ClassMethods
|
20
|
+
# Builds a new instance from a hash, symbolizing keys as needed.
|
21
|
+
#
|
22
|
+
# @param hash [Hash] input data
|
23
|
+
# @return [Castkit::DataObject] deserialized instance
|
24
|
+
def from_hash(hash)
|
25
|
+
hash = hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
|
26
|
+
new(hash)
|
27
|
+
end
|
28
|
+
|
29
|
+
# @!method from_h(hash)
|
30
|
+
# Alias for {.from_hash}
|
31
|
+
alias from_h from_hash
|
32
|
+
|
33
|
+
# @!method deserialize(hash)
|
34
|
+
# Alias for {.from_hash}
|
35
|
+
alias deserialize from_hash
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# Loads and assigns all attributes from input hash.
|
41
|
+
#
|
42
|
+
# @param input [Hash] the input data
|
43
|
+
# @return [void]
|
44
|
+
def deserialize_attributes!(input)
|
45
|
+
self.class.attributes.each_value do |attribute|
|
46
|
+
next if attribute.skip_deserialization?
|
47
|
+
|
48
|
+
value = resolve_input_value(input, attribute)
|
49
|
+
next if value.nil? && attribute.optional?
|
50
|
+
|
51
|
+
value = deserialize_attribute_value!(attribute, value)
|
52
|
+
instance_variable_set("@#{attribute.field}", value)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Deserializes an attribute's value according to its type.
|
57
|
+
#
|
58
|
+
# @param attribute [Castkit::Attribute]
|
59
|
+
# @param value [Object]
|
60
|
+
# @return [Object]
|
61
|
+
def deserialize_attribute_value!(attribute, value)
|
62
|
+
value = attribute.default if value.nil?
|
63
|
+
raise Castkit::AttributeError, "#{attribute.field} cannot be nil" if required?(attribute, value)
|
64
|
+
|
65
|
+
if attribute.dataobject?
|
66
|
+
attribute.type.cast(value)
|
67
|
+
elsif attribute.dataobject_collection?
|
68
|
+
Array(value).map { |v| attribute.options[:of].cast(v) }
|
69
|
+
else
|
70
|
+
deserialize_primitive_value!(attribute, value)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Attempts to deserialize a primitive or union-typed value.
|
75
|
+
#
|
76
|
+
# @param attribute [Castkit::Attribute]
|
77
|
+
# @param value [Object]
|
78
|
+
# @return [Object]
|
79
|
+
# @raise [Castkit::AttributeError] if no type matches
|
80
|
+
def deserialize_primitive_value!(attribute, value)
|
81
|
+
Array(attribute.type).each do |type|
|
82
|
+
return Castkit.type_deserializer(type).call(value)
|
83
|
+
rescue Castkit::TypeError, Castkit::AttributeError
|
84
|
+
next
|
85
|
+
end
|
86
|
+
|
87
|
+
raise Castkit::AttributeError,
|
88
|
+
"#{attribute.field} could not be deserialized into any of #{attribute.type.inspect}"
|
89
|
+
end
|
90
|
+
|
91
|
+
# Checks whether an attribute is required and its value is nil.
|
92
|
+
#
|
93
|
+
# @param attribute [Castkit::Attribute]
|
94
|
+
# @param value [Object]
|
95
|
+
# @return [Boolean]
|
96
|
+
def required?(attribute, value)
|
97
|
+
value.nil? && attribute.required?
|
98
|
+
end
|
99
|
+
|
100
|
+
# Finds the first matching value for an attribute using key and alias paths.
|
101
|
+
#
|
102
|
+
# @param input [Hash]
|
103
|
+
# @param attribute [Castkit::Attribute]
|
104
|
+
# @return [Object, nil]
|
105
|
+
def resolve_input_value(input, attribute)
|
106
|
+
attribute.key_path(with_aliases: true).each do |path|
|
107
|
+
value = path.reduce(input) { |memo, key| memo.is_a?(Hash) ? memo[key] : nil }
|
108
|
+
return value unless value.nil?
|
109
|
+
end
|
110
|
+
|
111
|
+
nil
|
112
|
+
end
|
113
|
+
|
114
|
+
# Resolves root-wrapped and unwrapped data.
|
115
|
+
#
|
116
|
+
# @param data [Hash]
|
117
|
+
# @return [Hash] transformed input
|
118
|
+
def unwrap_root(data)
|
119
|
+
root = self.class.root
|
120
|
+
data = data[root] if root && data.key?(root)
|
121
|
+
|
122
|
+
unwrap_prefixed_fields!(data)
|
123
|
+
end
|
124
|
+
|
125
|
+
# Nests prefixed fields under their parent attribute for unwrapped dataobjects.
|
126
|
+
#
|
127
|
+
# @param data [Hash]
|
128
|
+
# @return [Hash] modified input
|
129
|
+
def unwrap_prefixed_fields!(data)
|
130
|
+
self.class.attributes.each_value do |attribute|
|
131
|
+
next unless attribute.unwrapped?
|
132
|
+
|
133
|
+
unwrapped, keys_to_remove = unwrap_prefixed_values(data, attribute)
|
134
|
+
next if unwrapped.empty?
|
135
|
+
|
136
|
+
data[attribute.field] = unwrapped
|
137
|
+
keys_to_remove.each { |k| data.delete(k) }
|
138
|
+
end
|
139
|
+
|
140
|
+
data
|
141
|
+
end
|
142
|
+
|
143
|
+
# Extracts and strips prefixed keys for unwrapped nested attributes.
|
144
|
+
#
|
145
|
+
# @param data [Hash]
|
146
|
+
# @param attribute [Castkit::Attribute]
|
147
|
+
# @return [Array<(Hash, Array<Symbol>)] extracted subhash and deleted keys
|
148
|
+
def unwrap_prefixed_values(data, attribute)
|
149
|
+
prefix = attribute.prefix.to_s
|
150
|
+
unwrapped_data = {}
|
151
|
+
keys_to_remove = []
|
152
|
+
|
153
|
+
data.each do |k, v|
|
154
|
+
k_str = k.to_s
|
155
|
+
next unless k_str.start_with?(prefix)
|
156
|
+
|
157
|
+
stripped = k_str.sub(prefix, "").to_sym
|
158
|
+
unwrapped_data[stripped] = v
|
159
|
+
keys_to_remove << k
|
160
|
+
end
|
161
|
+
|
162
|
+
[unwrapped_data, keys_to_remove]
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castkit
|
4
|
+
module Ext
|
5
|
+
module DataObject
|
6
|
+
# Provides per-class serialization configuration for Castkit::Dataobject, including
|
7
|
+
# root key handling and ignore rules.
|
8
|
+
module Serialization
|
9
|
+
# Automatically extends class-level methods when included.
|
10
|
+
#
|
11
|
+
# @param base [Class]
|
12
|
+
def self.included(base)
|
13
|
+
base.extend(ClassMethods)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Class-level configuration methods.
|
17
|
+
module ClassMethods
|
18
|
+
# Sets or retrieves the root key to wrap the object under during (de)serialization.
|
19
|
+
#
|
20
|
+
# @param value [String, Symbol, nil] optional root key
|
21
|
+
# @return [Symbol, nil]
|
22
|
+
def root(value = nil)
|
23
|
+
value.nil? ? @root : (@root = value.to_s.strip.to_sym)
|
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
|
+
end
|
44
|
+
|
45
|
+
# Returns the root key for this instance.
|
46
|
+
#
|
47
|
+
# @return [Symbol]
|
48
|
+
def root_key
|
49
|
+
self.class.root.to_s.strip.to_sym
|
50
|
+
end
|
51
|
+
|
52
|
+
# Whether a root key is configured for this instance.
|
53
|
+
#
|
54
|
+
# @return [Boolean]
|
55
|
+
def root_key_set?
|
56
|
+
!self.class.root.to_s.strip.empty?
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castkit
|
4
|
+
# Provides string transformation utilities used internally by Castkit
|
5
|
+
module Inflector
|
6
|
+
class << self
|
7
|
+
# Returns the unqualified class name from a namespaced class.
|
8
|
+
#
|
9
|
+
# @example
|
10
|
+
# Castkit::Inflector.class_name(Foo::Bar) # => "Bar"
|
11
|
+
#
|
12
|
+
# @param klass [Class]
|
13
|
+
# @return [String]
|
14
|
+
def unqualified_name(klass)
|
15
|
+
klass.name.to_s.split("::").last
|
16
|
+
end
|
17
|
+
|
18
|
+
# Converts a snake_case or underscored string into PascalCase.
|
19
|
+
#
|
20
|
+
# @example
|
21
|
+
# Castkit::Inflector.pascalize("user_contract") # => "UserContract"
|
22
|
+
# Castkit::Inflector.pascalize(:admin_dto) # => "AdminDto"
|
23
|
+
#
|
24
|
+
# @param string [String, Symbol] the input to convert
|
25
|
+
# @return [String] the PascalCase representation
|
26
|
+
def pascalize(string)
|
27
|
+
string.to_s.split("_").map(&:capitalize).join
|
28
|
+
end
|
29
|
+
|
30
|
+
# Converts a PascalCase or camelCase string to snake_case.
|
31
|
+
#
|
32
|
+
# @example
|
33
|
+
# Castkit::Inflector.underscore("UserContract") # => "user_contract"
|
34
|
+
# Castkit::Inflector.underscore("XMLParser") # => "xml_parser"
|
35
|
+
#
|
36
|
+
# @param string [String, Symbol]
|
37
|
+
# @return [String]
|
38
|
+
def underscore(string)
|
39
|
+
string
|
40
|
+
.to_s
|
41
|
+
.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
42
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
43
|
+
.downcase
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "generic"
|
4
|
+
|
5
|
+
module Castkit
|
6
|
+
module Types
|
7
|
+
# Type definition for `:boolean` attributes.
|
8
|
+
#
|
9
|
+
# Converts strings or numbers into boolean values based on common truthy/falsy indicators.
|
10
|
+
#
|
11
|
+
# This class is used internally by Castkit when an attribute is defined with:
|
12
|
+
# `boolean :is_active`
|
13
|
+
class Boolean < Generic
|
14
|
+
# Deserializes the input into a boolean value.
|
15
|
+
#
|
16
|
+
# Accepts:
|
17
|
+
# - `"true"`, `"1"` (case-insensitive) → `true`
|
18
|
+
# - `"false"`, `"0"` (case-insensitive) → `false`
|
19
|
+
#
|
20
|
+
# @param value [Object]
|
21
|
+
# @return [Boolean]
|
22
|
+
# @raise [Castkit::TypeError] if the value cannot be coerced to a boolean
|
23
|
+
def deserialize(value)
|
24
|
+
case value.to_s.downcase
|
25
|
+
when "true", "1"
|
26
|
+
true
|
27
|
+
when "false", "0"
|
28
|
+
false
|
29
|
+
else
|
30
|
+
type_error!(:boolean, value)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Serializes the boolean value (pass-through).
|
35
|
+
#
|
36
|
+
# @param value [Boolean]
|
37
|
+
# @return [Boolean]
|
38
|
+
def serialize(value)
|
39
|
+
value
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "generic"
|
4
|
+
|
5
|
+
module Castkit
|
6
|
+
module Types
|
7
|
+
# Type definition for `:array` attributes.
|
8
|
+
#
|
9
|
+
# Wraps any value in an array using `Array(value)` coercion. This ensures consistent array representation
|
10
|
+
# even if the input is a single value or nil.
|
11
|
+
#
|
12
|
+
# This class is used internally by Castkit when an attribute is defined with:
|
13
|
+
# `array :tags, of: :string`
|
14
|
+
class Collection < Generic
|
15
|
+
# Deserializes the value into an array using `Array(value)`.
|
16
|
+
#
|
17
|
+
# @param value [Object]
|
18
|
+
# @return [::Array]
|
19
|
+
def deserialize(value)
|
20
|
+
Array(value)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "date"
|
4
|
+
require_relative "generic"
|
5
|
+
|
6
|
+
module Castkit
|
7
|
+
module Types
|
8
|
+
# Type definition for `:date` attributes.
|
9
|
+
#
|
10
|
+
# Handles deserialization from strings and other input into `Date` objects,
|
11
|
+
# and serializes `Date` values into ISO8601 strings.
|
12
|
+
#
|
13
|
+
# This class is used internally by Castkit when an attribute is defined with:
|
14
|
+
# `date :published_on`
|
15
|
+
class Date < Generic
|
16
|
+
# Deserializes the input value to a `Date` instance.
|
17
|
+
#
|
18
|
+
# @param value [Object]
|
19
|
+
# @return [::Date]
|
20
|
+
# @raise [ArgumentError] if parsing fails
|
21
|
+
def deserialize(value)
|
22
|
+
::Date.parse(value.to_s)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Serializes a `Date` object to ISO8601 string format.
|
26
|
+
#
|
27
|
+
# @param value [::Date]
|
28
|
+
# @return [String]
|
29
|
+
def serialize(value)
|
30
|
+
value.iso8601
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "date"
|
4
|
+
require_relative "generic"
|
5
|
+
|
6
|
+
module Castkit
|
7
|
+
module Types
|
8
|
+
# Type definition for `:datetime` attributes.
|
9
|
+
#
|
10
|
+
# Handles deserialization from strings and other input into `DateTime` objects,
|
11
|
+
# and serializes `DateTime` values into ISO8601 strings.
|
12
|
+
#
|
13
|
+
# This class is used internally by Castkit when an attribute is defined with:
|
14
|
+
# `datetime :published_ad`
|
15
|
+
class DateTime < Generic
|
16
|
+
# Deserializes the input value to a `DateTime` instance.
|
17
|
+
#
|
18
|
+
# @param value [Object]
|
19
|
+
# @return [::DateTime]
|
20
|
+
# @raise [ArgumentError] if parsing fails
|
21
|
+
def deserialize(value)
|
22
|
+
::DateTime.parse(value.to_s)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Serializes a `DateTime` object to ISO8601 string format.
|
26
|
+
#
|
27
|
+
# @param value [::DateTime]
|
28
|
+
# @return [String]
|
29
|
+
def serialize(value)
|
30
|
+
value.iso8601
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "generic"
|
4
|
+
require_relative "../validators/numeric_validator"
|
5
|
+
|
6
|
+
module Castkit
|
7
|
+
module Types
|
8
|
+
# Type definition for `:integer` attributes.
|
9
|
+
#
|
10
|
+
# Handles deserialization from raw input (e.g., strings, floats) to Float,
|
11
|
+
# applies optional numeric validation rules (e.g., `min`, `max`), and returns
|
12
|
+
# the value unchanged during serialization.
|
13
|
+
#
|
14
|
+
# This class is used internally by Castkit when an attribute is defined with:
|
15
|
+
# `integer :count`
|
16
|
+
class Float < Generic
|
17
|
+
# Deserializes the input value to an Float.
|
18
|
+
#
|
19
|
+
# @param value [Object]
|
20
|
+
# @return [Float]
|
21
|
+
def deserialize(value)
|
22
|
+
value.to_f
|
23
|
+
end
|
24
|
+
|
25
|
+
# Serializes the Float value.
|
26
|
+
#
|
27
|
+
# @param value [Float]
|
28
|
+
# @return [Float]
|
29
|
+
def serialize(value)
|
30
|
+
value
|
31
|
+
end
|
32
|
+
|
33
|
+
# Validates the Float value using Castkit's NumericValidator.
|
34
|
+
#
|
35
|
+
# Supports options like `min:` and `max:`.
|
36
|
+
#
|
37
|
+
# @param value [Object]
|
38
|
+
# @param options [Hash] validation options
|
39
|
+
# @param context [Symbol, String] attribute context for error messages
|
40
|
+
# @return [void]
|
41
|
+
def validate!(value, options: {}, context: {})
|
42
|
+
Castkit::Validators::NumericValidator.call(value, options: options, context: context)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castkit
|
4
|
+
module Types
|
5
|
+
# Generic base class for type definitions in Castkit.
|
6
|
+
#
|
7
|
+
# Provides default behavior for (de)serialization, validation, and coercion.
|
8
|
+
# All primitive types should subclass this and override methods as needed.
|
9
|
+
#
|
10
|
+
# The `cast!` method is the primary entry point used by attribute processing
|
11
|
+
# to validate and coerce values in a predictable order.
|
12
|
+
class Generic
|
13
|
+
class << self
|
14
|
+
# Coerces and validates a value for use in a Castkit DataObject.
|
15
|
+
#
|
16
|
+
# When `force_type` is true, the value is deserialized (coerced) first,
|
17
|
+
# then validated. This is useful when a value may need to be converted
|
18
|
+
# before it can pass validation (e.g. `"123"` → `123`).
|
19
|
+
#
|
20
|
+
# Otherwise, the raw value is validated before coercion.
|
21
|
+
#
|
22
|
+
# @param value [Object] the input value
|
23
|
+
# @param validator [#call, nil] optional custom validator (default uses `validate!`)
|
24
|
+
# @param options [Hash] options passed to `validate!`, e.g., `min`, `max`, `force_type`
|
25
|
+
# @param context [Symbol, String, nil] context label for error messages
|
26
|
+
# @return [Object] the deserialized and validated value
|
27
|
+
def cast!(value, validator: nil, options: {}, context: {})
|
28
|
+
instance = new
|
29
|
+
validator ||= options.delete(:validator)
|
30
|
+
validator ||= default_validator(instance)
|
31
|
+
|
32
|
+
if options[:force_type]
|
33
|
+
deserialized_value = instance.deserialize(value)
|
34
|
+
validator.call(deserialized_value, options: options, context: context)
|
35
|
+
return deserialized_value
|
36
|
+
end
|
37
|
+
|
38
|
+
validator.call(value, options: options, context: context)
|
39
|
+
instance.deserialize(value)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Deserializes the value using the default type behavior.
|
43
|
+
#
|
44
|
+
# @param value [Object]
|
45
|
+
# @return [Object] the coerced value
|
46
|
+
def deserialize(value)
|
47
|
+
new.deserialize(value)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Serializes the value using the default type behavior.
|
51
|
+
#
|
52
|
+
# @param value [Object]
|
53
|
+
# @return [Object]
|
54
|
+
def serialize(value)
|
55
|
+
new.serialize(value)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Validates the value using the default validator.
|
59
|
+
#
|
60
|
+
# @param value [Object] the value to check
|
61
|
+
# @param options [Hash] validation rules (e.g., min, max, format)
|
62
|
+
# @param context [Symbol, String] label for error reporting
|
63
|
+
# @return [void]
|
64
|
+
def validate!(value, options: {}, context: {})
|
65
|
+
new.validate!(value, options: options, context: context)
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
# Builds a default validator from the instance itself.
|
71
|
+
#
|
72
|
+
# @param instance [Castkit::Types::Generic]
|
73
|
+
# @return [Proc] a lambda wrapping `#validate!`
|
74
|
+
def default_validator(instance)
|
75
|
+
lambda do |value, options: {}, context: nil|
|
76
|
+
instance.validate!(value, options: options, context: context)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Deserializes the value. Override in subclasses to coerce input (e.g., `"123"` → `123`).
|
82
|
+
#
|
83
|
+
# @param value [Object]
|
84
|
+
# @return [Object]
|
85
|
+
def deserialize(value)
|
86
|
+
value
|
87
|
+
end
|
88
|
+
|
89
|
+
# Serializes the value. Override in subclasses if the output should be transformed.
|
90
|
+
#
|
91
|
+
# @param value [Object]
|
92
|
+
# @return [Object]
|
93
|
+
def serialize(value)
|
94
|
+
value
|
95
|
+
end
|
96
|
+
|
97
|
+
# Validates the value. No-op by default.
|
98
|
+
#
|
99
|
+
# @param value [Object]
|
100
|
+
# @param options [Hash]
|
101
|
+
# @param context [Symbol, String]
|
102
|
+
# @return [void]
|
103
|
+
def validate!(value, options: {}, context: {})
|
104
|
+
# override in subclasses
|
105
|
+
end
|
106
|
+
|
107
|
+
protected
|
108
|
+
|
109
|
+
# Emits or raises a type error depending on configuration.
|
110
|
+
#
|
111
|
+
# @param type [Symbol]
|
112
|
+
# @param value [Object, nil]
|
113
|
+
# @return [void]
|
114
|
+
def type_error!(type, value)
|
115
|
+
message = "value must be a #{type}, got #{value.inspect}"
|
116
|
+
|
117
|
+
raise Castkit::TypeError, message if Castkit.configuration.raise_type_errors
|
118
|
+
|
119
|
+
Castkit.warning "[Castkit] #{message}" if Castkit.configuration.enable_warnings
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|