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,248 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../error"
|
4
|
+
|
5
|
+
module Castkit
|
6
|
+
module Contract
|
7
|
+
# Responsible for validating input against a set of Castkit::Attribute definitions.
|
8
|
+
#
|
9
|
+
# The validator supports:
|
10
|
+
# - Primitive type validation
|
11
|
+
# - Nested Castkit::DataObject validation
|
12
|
+
# - Collections of DataObjects
|
13
|
+
# - Strict or relaxed key handling
|
14
|
+
#
|
15
|
+
# Returns a hash of errors if validation fails, keyed by attribute name.
|
16
|
+
class Validator
|
17
|
+
class << self
|
18
|
+
# Validates input against the provided attribute definitions.
|
19
|
+
#
|
20
|
+
# @param attributes [Array<Castkit::Attribute>] the attributes to validate
|
21
|
+
# @param input [Hash] the raw input data
|
22
|
+
# @param options [Hash] validator options:
|
23
|
+
# - strict: whether unknown keys should raise
|
24
|
+
# - allow_unknown: whether unknown keys are permitted
|
25
|
+
# - warn_on_unknown: whether to log warnings for unknown keys
|
26
|
+
#
|
27
|
+
# @return [Hash{Symbol => String, Hash, nil}] validation errors per attribute
|
28
|
+
def call(attributes, input, **options)
|
29
|
+
new(attributes, **options).call(input)
|
30
|
+
end
|
31
|
+
|
32
|
+
def call!(attributes, input, **options)
|
33
|
+
errors = call(attributes, input, **options)
|
34
|
+
raise Castkit::ContractError.new("Validation failed", errors: errors) if errors.any?
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Executes validation against the input data.
|
39
|
+
#
|
40
|
+
# @param input [Hash] the incoming data to validate
|
41
|
+
# @return [Hash{Symbol => String, Hash}] validation errors, empty if valid
|
42
|
+
def call(input)
|
43
|
+
validate_access_config!
|
44
|
+
errors = {}
|
45
|
+
|
46
|
+
@attributes.each do |attribute|
|
47
|
+
value = resolve_input_value(input, attribute)
|
48
|
+
error = validate_attribute(attribute, value)
|
49
|
+
|
50
|
+
errors[attribute.field] = error if error
|
51
|
+
end
|
52
|
+
|
53
|
+
validate_unknown_attributes!(input, errors)
|
54
|
+
errors
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
# @param attributes [Array<Castkit::Attribute>] attributes to validate
|
60
|
+
# @param options [Hash] validation options
|
61
|
+
def initialize(attributes, **options)
|
62
|
+
@attributes = attributes
|
63
|
+
@options = options
|
64
|
+
end
|
65
|
+
|
66
|
+
# Validates a single attribute value.
|
67
|
+
#
|
68
|
+
# @param attribute [Castkit::Attribute]
|
69
|
+
# @param value [Object]
|
70
|
+
# @return [String, Hash, nil] error message, nested error hash, or nil if valid
|
71
|
+
def validate_attribute(attribute, value)
|
72
|
+
return nil if value.nil? && attribute.optional?
|
73
|
+
return "#{attribute.field} is required" if value.nil? && attribute.required?
|
74
|
+
|
75
|
+
if attribute.dataobject?
|
76
|
+
validate_nested_dataobject(attribute, value)
|
77
|
+
elsif attribute.type == :array
|
78
|
+
validate_nested_array(attribute, value)
|
79
|
+
else
|
80
|
+
validate_primitives(attribute, value)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Validates a nested DataObject instance.
|
85
|
+
#
|
86
|
+
# @param attribute [Castkit::Attribute]
|
87
|
+
# @param value [Castkit::DataObject]
|
88
|
+
# @return [Hash, nil] validation errors or nil
|
89
|
+
def validate_nested_dataobject(attribute, value)
|
90
|
+
return nil unless value.respond_to?(:to_h)
|
91
|
+
|
92
|
+
validate_nested(attribute, value, attribute.type)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Validates a collection of nested DataObject instances.
|
96
|
+
#
|
97
|
+
# @param attribute [Castkit::Attribute]
|
98
|
+
# @param value [Array<Castkit::DataObject | Symbol>]
|
99
|
+
# @return [Hash{Integer => Hash}, String, nil] indexed validation errors or message
|
100
|
+
def validate_nested_array(attribute, value)
|
101
|
+
return "must be an array" unless value.is_a?(Array)
|
102
|
+
|
103
|
+
errors = {}
|
104
|
+
|
105
|
+
value.each_with_index do |item, index|
|
106
|
+
error = validate_nested(attribute, item, attribute.options[:of], context: "#{attribute.field}[#{index}]")
|
107
|
+
errors[index] = error if error
|
108
|
+
end
|
109
|
+
|
110
|
+
errors.empty? ? nil : errors
|
111
|
+
end
|
112
|
+
|
113
|
+
# Helper method used to validate nested types for Castkit::DataObject and array types.
|
114
|
+
#
|
115
|
+
# @param attribute [Castkit::Attribute]
|
116
|
+
# @param value [Object] the data to validate against
|
117
|
+
# @param type [Castkit::DataObject, Symbol]
|
118
|
+
def validate_nested(attribute, value, type, context: nil)
|
119
|
+
return validate_primitive_type(attribute, value, type, context: context) unless Castkit.dataobject?(type)
|
120
|
+
|
121
|
+
errors = Castkit::Contract::Validator.call(
|
122
|
+
type.attributes.values,
|
123
|
+
value.to_h,
|
124
|
+
**dataobject_options(type)
|
125
|
+
)
|
126
|
+
|
127
|
+
errors.empty? ? nil : errors
|
128
|
+
end
|
129
|
+
|
130
|
+
# Validates a primitive value against a union of allowed types.
|
131
|
+
#
|
132
|
+
# Attempts each type in order until one successfully casts and validates.
|
133
|
+
# If all types fail, the last error message is returned.
|
134
|
+
#
|
135
|
+
# @param attribute [Castkit::Attribute] the attribute definition
|
136
|
+
# @param value [Object] the value to validate
|
137
|
+
# @return [String, nil] the validation error message, or nil if valid
|
138
|
+
def validate_primitives(attribute, value)
|
139
|
+
last_error = nil
|
140
|
+
|
141
|
+
Array(attribute.type).each do |type|
|
142
|
+
last_error = validate_primitive_type(attribute, value, type)
|
143
|
+
return nil if last_error.nil?
|
144
|
+
end
|
145
|
+
|
146
|
+
last_error || "could not match attribute type(s): #{attribute.type.inspect}"
|
147
|
+
end
|
148
|
+
|
149
|
+
# Validates a primitive value against a specific type.
|
150
|
+
#
|
151
|
+
# @param attribute [Castkit::Attribute] the attribute definition
|
152
|
+
# @param value [Object] the value to validate
|
153
|
+
# @param type [Symbol, Class] the specific type to attempt validation with
|
154
|
+
# @return [String, nil] the error message if validation fails, otherwise nil
|
155
|
+
def validate_primitive_type(attribute, value, type, context: nil)
|
156
|
+
context ||= attribute.field
|
157
|
+
|
158
|
+
Castkit.type_caster(type).call(
|
159
|
+
value,
|
160
|
+
validator: attribute.options[:validator],
|
161
|
+
options: attribute.options,
|
162
|
+
context: context
|
163
|
+
)
|
164
|
+
|
165
|
+
nil
|
166
|
+
rescue Castkit::AttributeError => e
|
167
|
+
e.message
|
168
|
+
end
|
169
|
+
|
170
|
+
# Validates unknown attributes based on strict/allow config.
|
171
|
+
#
|
172
|
+
# @param input [Hash]
|
173
|
+
# @param errors [Hash]
|
174
|
+
# @return [void]
|
175
|
+
def validate_unknown_attributes!(input, errors)
|
176
|
+
return if @options[:allow_unknown]
|
177
|
+
|
178
|
+
unknown_keys = unknown_attributes(input)
|
179
|
+
unknown_keys.each { |key| errors[key] = "#{key} is not allowed" } if @options[:strict]
|
180
|
+
|
181
|
+
handle_unknown_keys!(unknown_keys) unless unknown_keys.empty?
|
182
|
+
end
|
183
|
+
|
184
|
+
# Resolves the value for a given attribute from the input hash.
|
185
|
+
#
|
186
|
+
# @param input [Hash]
|
187
|
+
# @param attribute [Castkit::Attribute]
|
188
|
+
# @return [Object, nil]
|
189
|
+
def resolve_input_value(input, attribute)
|
190
|
+
attribute.key_path(with_aliases: true).each do |path|
|
191
|
+
value = path.reduce(input) { |memo, key| memo.is_a?(Hash) ? memo[key] : nil }
|
192
|
+
return value unless value.nil?
|
193
|
+
end
|
194
|
+
|
195
|
+
nil
|
196
|
+
end
|
197
|
+
|
198
|
+
# Collects all keys present in the input hash that don't have attribute definitions.
|
199
|
+
#
|
200
|
+
# @param input [Hash]
|
201
|
+
# @return [Array<Symbol>]
|
202
|
+
def unknown_attributes(input)
|
203
|
+
valid_keys = @attributes.flat_map { |attr| [attr.key] + attr.options[:aliases] }.map(&:to_sym).uniq
|
204
|
+
input.keys.map(&:to_sym) - valid_keys
|
205
|
+
end
|
206
|
+
|
207
|
+
# Warns if both `strict` and `allow_unknown` are enabled.
|
208
|
+
#
|
209
|
+
# @return [void]
|
210
|
+
def validate_access_config!
|
211
|
+
return unless @options[:strict] && @options[:allow_unknown]
|
212
|
+
|
213
|
+
Castkit.warning "⚠️ [Castkit] Both `strict` and `allow_unknown` are enabled, which can lead to " \
|
214
|
+
"conflicting behavior. `strict` is being disabled to respect `allow_unknown`."
|
215
|
+
end
|
216
|
+
|
217
|
+
# Raises or warns on unknown input keys based on config.
|
218
|
+
#
|
219
|
+
# @param unknown_keys [Array<Symbol>]
|
220
|
+
# @return [void]
|
221
|
+
def handle_unknown_keys!(unknown_keys)
|
222
|
+
raise Castkit::ContractError, "Unknown attribute(s): #{unknown_keys.join(", ")}" if strict?
|
223
|
+
return unless @options[:warn_on_unknown]
|
224
|
+
|
225
|
+
Castkit.warning "⚠️ [Castkit] Unknown attribute(s) ignored: #{unknown_keys.join(", ")}"
|
226
|
+
end
|
227
|
+
|
228
|
+
# Returns nested validation options from a DataObject class.
|
229
|
+
#
|
230
|
+
# @param obj [Castkit::DataObject]
|
231
|
+
# @return [Hash]
|
232
|
+
def dataobject_options(obj)
|
233
|
+
{
|
234
|
+
strict: obj.strict,
|
235
|
+
allow_unknown: obj.allow_unknown,
|
236
|
+
warn_on_unknown: obj.warn_on_unknown
|
237
|
+
}
|
238
|
+
end
|
239
|
+
|
240
|
+
# Whether strict validation mode is enabled (unless allow_unknown overrides).
|
241
|
+
#
|
242
|
+
# @return [Boolean]
|
243
|
+
def strict?
|
244
|
+
@options[:allow_unknown] ? false : !!@options[:strict]
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "contract/generic"
|
4
|
+
|
5
|
+
module Castkit
|
6
|
+
# Castkit::Contract provides a lightweight mechanism for defining and validating
|
7
|
+
# structured input using a DSL similar to Castkit::DataObject, but without requiring
|
8
|
+
# a full data model. Contracts are ideal for validating operation inputs, service payloads,
|
9
|
+
# or external API request data.
|
10
|
+
#
|
11
|
+
# Contracts support primitive type coercion, nested data object validation, and configurable
|
12
|
+
# strictness for unknown attributes. Each contract is defined as a standalone class
|
13
|
+
# with its own rules and validation logic.
|
14
|
+
module Contract
|
15
|
+
class << self
|
16
|
+
# Builds a contract from a DSL block and optional validation rules.
|
17
|
+
#
|
18
|
+
# @example Using a block to define a contract
|
19
|
+
# UserContract = Castkit::Contract.build(:user) do
|
20
|
+
# string :id
|
21
|
+
# string :email, required: false
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# UserContract.validate!(id: "abc") # => passes
|
25
|
+
#
|
26
|
+
# @example With custom validation rules
|
27
|
+
# LooseContract = Castkit::Contract.build(:loose, strict: false) do
|
28
|
+
# string :token
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# @param name [String, Symbol, nil] Optional name for the contract.
|
32
|
+
# @param validation_rules [Hash] Optional validation rules (e.g., `strict: true`).
|
33
|
+
# @yield Optional DSL block to define attributes.
|
34
|
+
# @return [Class<Castkit::Contract::Generic>]
|
35
|
+
def build(name = nil, **validation_rules, &block)
|
36
|
+
klass = Class.new(Castkit::Contract::Generic)
|
37
|
+
klass.send(:define, name, nil, validation_rules: validation_rules, &block)
|
38
|
+
|
39
|
+
klass
|
40
|
+
end
|
41
|
+
|
42
|
+
# Builds a contract from an existing Castkit::DataObject class.
|
43
|
+
#
|
44
|
+
# @example Generating a contract from a DTO
|
45
|
+
# class UserDto < Castkit::DataObject
|
46
|
+
# string :id
|
47
|
+
# string :email
|
48
|
+
# end
|
49
|
+
#
|
50
|
+
# UserContract = Castkit::Contract.from_dataobject(UserDto)
|
51
|
+
# UserContract.validate!(id: "123", email: "a@example.com")
|
52
|
+
#
|
53
|
+
# @param source [Class<Castkit::DataObject>] the DataObject to generate the contract from
|
54
|
+
# @param as [String, Symbol, nil] Optional custom name to use for the contract
|
55
|
+
# @return [Class<Castkit::Contract::Generic>]
|
56
|
+
def from_dataobject(source, as: nil)
|
57
|
+
name = as || Castkit::Inflector.unqualified_name(source)
|
58
|
+
name = Castkit::Inflector.underscore(name).to_sym
|
59
|
+
|
60
|
+
klass = Class.new(Castkit::Contract::Generic)
|
61
|
+
klass.send(:define, name, source, validation_rules: source.validation_rules)
|
62
|
+
|
63
|
+
klass
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -1,11 +1,31 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Castkit
|
4
|
-
module
|
4
|
+
module Core
|
5
5
|
# Provides DSL methods to define typed attributes in a Castkit::DataObject.
|
6
6
|
#
|
7
7
|
# These helpers are shortcuts for calling `attribute` with a specific type.
|
8
8
|
module AttributeTypes
|
9
|
+
# Inclusion hook: ensures config is loaded and extends the including class with DSL
|
10
|
+
def self.included(base)
|
11
|
+
Castkit.configuration # triggers type/alias registration
|
12
|
+
base.extend(self)
|
13
|
+
end
|
14
|
+
|
15
|
+
class << self
|
16
|
+
# Dynamically defines a DSL method for a registered type.
|
17
|
+
#
|
18
|
+
# @param type [Symbol] the name of the type
|
19
|
+
# @return [void]
|
20
|
+
def define_type_dsl(type)
|
21
|
+
AttributeTypes.module_eval do
|
22
|
+
define_method(type) do |field, **options|
|
23
|
+
attribute(field, type.to_sym, **options)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
9
29
|
# Defines a string attribute.
|
10
30
|
#
|
11
31
|
# @param field [Symbol]
|
@@ -95,12 +115,6 @@ module Castkit
|
|
95
115
|
attribute(field, type, **options, unwrapped: true)
|
96
116
|
end
|
97
117
|
|
98
|
-
# Alias for `array`
|
99
|
-
alias collection array
|
100
|
-
|
101
|
-
# Alias for `dataobject`
|
102
|
-
alias object dataobject
|
103
|
-
|
104
118
|
# Alias for `dataobject`
|
105
119
|
alias dto dataobject
|
106
120
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Castkit
|
4
|
-
module
|
4
|
+
module Core
|
5
5
|
# Provides DSL and implementation for declaring attributes within a Castkit::DataObject.
|
6
6
|
#
|
7
7
|
# Includes support for regular, composite, transient, readonly/writeonly, and grouped attribute definitions.
|
@@ -127,8 +127,13 @@ module Castkit
|
|
127
127
|
# @param attribute [Castkit::Attribute]
|
128
128
|
def define_typed_writer(field, attribute)
|
129
129
|
define_method("#{field}=") do |value|
|
130
|
-
|
131
|
-
|
130
|
+
deserialized_value = Castkit.type_caster(attribute.type).call(
|
131
|
+
value,
|
132
|
+
options: attribute.options,
|
133
|
+
context: attribute.field
|
134
|
+
)
|
135
|
+
|
136
|
+
instance_variable_set("@#{field}", deserialized_value)
|
132
137
|
end
|
133
138
|
end
|
134
139
|
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castkit
|
4
|
+
module Core
|
5
|
+
# Provides per-class configuration for a Castkit::DataObject,
|
6
|
+
# including root key handling, strict mode, and unknown key behavior.
|
7
|
+
module Config
|
8
|
+
# Sets or retrieves strict mode behavior.
|
9
|
+
#
|
10
|
+
# In strict mode, unknown keys during deserialization raise errors. If unset, falls back
|
11
|
+
# to `Castkit.configuration.strict_by_default`.
|
12
|
+
#
|
13
|
+
# @param value [Boolean, nil]
|
14
|
+
# @return [Boolean]
|
15
|
+
def strict(value = nil)
|
16
|
+
if value.nil?
|
17
|
+
@strict.nil? ? Castkit.configuration.strict_by_default : @strict
|
18
|
+
else
|
19
|
+
@strict = !!value
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Enables or disables ignoring unknown keys during deserialization.
|
24
|
+
#
|
25
|
+
# This is the inverse of `strict`.
|
26
|
+
#
|
27
|
+
# @param value [Boolean]
|
28
|
+
# @return [void]
|
29
|
+
def ignore_unknown(value = nil)
|
30
|
+
@strict = !value
|
31
|
+
end
|
32
|
+
|
33
|
+
# Sets or retrieves whether to emit warnings when unknown keys are encountered.
|
34
|
+
#
|
35
|
+
# @param value [Boolean, nil]
|
36
|
+
# @return [Boolean, nil]
|
37
|
+
def warn_on_unknown(value = nil)
|
38
|
+
value.nil? ? @warn_on_unknown : (@warn_on_unknown = value)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Sets or retrieves whether to allow unknown keys when they are encountered.
|
42
|
+
#
|
43
|
+
# @param value [Boolean, nil]
|
44
|
+
# @return [Boolean, nil]
|
45
|
+
def allow_unknown(value = nil)
|
46
|
+
value.nil? ? @allow_unknown : (@allow_unknown = value)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Returns a relaxed version of the current class with strict mode off.
|
50
|
+
#
|
51
|
+
# Useful for tolerant parsing scenarios.
|
52
|
+
#
|
53
|
+
# @param warn_on_unknown [Boolean]
|
54
|
+
# @return [Class] a subclass with relaxed rules
|
55
|
+
def relaxed(warn_on_unknown: true)
|
56
|
+
klass = Class.new(self)
|
57
|
+
klass.strict(false)
|
58
|
+
klass.warn_on_unknown(warn_on_unknown)
|
59
|
+
klass
|
60
|
+
end
|
61
|
+
|
62
|
+
# Returns a hash of config settings used during validation.
|
63
|
+
#
|
64
|
+
# @return [Hash{Symbol => Boolean}]
|
65
|
+
def validation_rules
|
66
|
+
@validation_rules ||= {
|
67
|
+
strict: strict,
|
68
|
+
allow_unknown: allow_unknown,
|
69
|
+
warn_on_unknown: warn_on_unknown
|
70
|
+
}
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../castkit"
|
4
|
+
|
5
|
+
module Castkit
|
6
|
+
module Core
|
7
|
+
# Provides methods to register dynamically generated contracts and data objects
|
8
|
+
# into the appropriate Castkit namespaces (`Castkit::Contracts`, `Castkit::DataObjects`).
|
9
|
+
#
|
10
|
+
# This is useful when working with ephemeral classes (e.g., from `Contract.build` or
|
11
|
+
# `.to_dataobject`) that should be persisted and referenced as constants.
|
12
|
+
#
|
13
|
+
# @example Registering a contract class
|
14
|
+
# contract = Castkit::Contract.build(:user) { string :id }
|
15
|
+
# contract.extend(Castkit::Core::Registerable)
|
16
|
+
# contract.register! # => Castkit::Contracts::User
|
17
|
+
#
|
18
|
+
# @example Registering a DTO
|
19
|
+
# dto = contract.to_dataobject
|
20
|
+
# dto.extend(Castkit::Core::Registerable)
|
21
|
+
# dto.register!(as: :UserDto) # => Castkit::DataObjects::UserDto
|
22
|
+
module Registerable
|
23
|
+
CASTKIT_NAMESPACES = {
|
24
|
+
contracts: Castkit::Contracts,
|
25
|
+
dataobjects: Castkit::DataObjects
|
26
|
+
}.freeze
|
27
|
+
|
28
|
+
# Registers the current class in the specified Castkit namespace.
|
29
|
+
#
|
30
|
+
# @param namespace [Symbol] `:contracts` or `:dataobjects`
|
31
|
+
# @param as [String, Symbol, nil] Optional constant name override (PascalCase).
|
32
|
+
# If not provided, falls back to the class's name (via `Inflector.pascalize(self.name)`).
|
33
|
+
#
|
34
|
+
# @raise [Castkit::Error] if class is anonymous or name already exists in the namespace
|
35
|
+
# @return [Class] the registered class
|
36
|
+
def register!(namespace:, as: nil)
|
37
|
+
name = Castkit::Inflector.pascalize(as || self.name)
|
38
|
+
raise Castkit::Error, "Unable to register anonymous classes, use as: ClassName" if name.nil?
|
39
|
+
|
40
|
+
ns = Castkit.const_get(namespace.to_s.capitalize, false)
|
41
|
+
raise Castkit::Error, "#{name} is already registered in #{ns}" if defined_in_namespace?(ns, name)
|
42
|
+
|
43
|
+
ns.const_set(name, self)
|
44
|
+
self
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
# Checks whether a constant is already defined in the given namespace.
|
50
|
+
#
|
51
|
+
# @param namespace [Module] target module (e.g., `Castkit::Contracts`)
|
52
|
+
# @param name [String, Symbol]
|
53
|
+
# @return [Boolean]
|
54
|
+
def defined_in_namespace?(namespace, name)
|
55
|
+
namespace.const_defined?(name.to_s.to_sym, false)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/castkit/data_object.rb
CHANGED
@@ -4,10 +4,14 @@ require "json"
|
|
4
4
|
require_relative "error"
|
5
5
|
require_relative "attribute"
|
6
6
|
require_relative "default_serializer"
|
7
|
-
require_relative "
|
8
|
-
require_relative "
|
9
|
-
require_relative "
|
10
|
-
require_relative "
|
7
|
+
require_relative "contract/validator"
|
8
|
+
require_relative "core/config"
|
9
|
+
require_relative "core/attributes"
|
10
|
+
require_relative "core/attribute_types"
|
11
|
+
require_relative "core/registerable"
|
12
|
+
require_relative "ext/data_object/contract"
|
13
|
+
require_relative "ext/data_object/deserialization"
|
14
|
+
require_relative "ext/data_object/serialization"
|
11
15
|
|
12
16
|
module Castkit
|
13
17
|
# Base class for defining declarative, typed data transfer objects (DTOs).
|
@@ -25,13 +29,31 @@ module Castkit
|
|
25
29
|
# user = UserDto.new(name: "Alice", age: 30)
|
26
30
|
# user.to_json #=> '{"name":"Alice","age":30}'
|
27
31
|
class DataObject
|
28
|
-
extend Castkit::
|
29
|
-
extend Castkit::
|
32
|
+
extend Castkit::Core::Config
|
33
|
+
extend Castkit::Core::Attributes
|
34
|
+
extend Castkit::Core::AttributeTypes
|
35
|
+
extend Castkit::Core::Registerable
|
36
|
+
extend Castkit::Ext::DataObject::Contract
|
30
37
|
|
31
|
-
include Castkit::
|
32
|
-
include Castkit::
|
38
|
+
include Castkit::Ext::DataObject::Serialization
|
39
|
+
include Castkit::Ext::DataObject::Deserialization
|
33
40
|
|
34
41
|
class << self
|
42
|
+
# Registers the current class under `Castkit::DataObjects`.
|
43
|
+
#
|
44
|
+
# @param as [String, Symbol, nil] The constant name to use (PascalCase). Defaults to class name or "Anonymous".
|
45
|
+
# @return [Class] the registered dataobject class
|
46
|
+
def register!(as: nil)
|
47
|
+
super(namespace: :dataobjects, as: as)
|
48
|
+
end
|
49
|
+
|
50
|
+
def build(&block)
|
51
|
+
klass = Class.new(self)
|
52
|
+
klass.class_eval(&block) if block_given?
|
53
|
+
|
54
|
+
klass
|
55
|
+
end
|
56
|
+
|
35
57
|
# Gets or sets the serializer class to use for instances of this object.
|
36
58
|
#
|
37
59
|
# @param value [Class<Castkit::Serializer>, nil]
|
@@ -80,20 +102,16 @@ module Castkit
|
|
80
102
|
|
81
103
|
# Initializes the DTO from a hash of attributes.
|
82
104
|
#
|
83
|
-
# @param
|
105
|
+
# @param data [Hash] raw input hash
|
84
106
|
# @raise [Castkit::DataObjectError] if strict mode is enabled and unknown keys are present
|
85
|
-
def initialize(
|
86
|
-
@__raw =
|
87
|
-
|
88
|
-
root = self.class.root
|
89
|
-
fields = fields[root] if root && fields.key?(root)
|
90
|
-
fields = unwrap_prefixed_fields!(fields)
|
107
|
+
def initialize(data = {})
|
108
|
+
@__raw = data.dup.freeze
|
109
|
+
data = unwrap_root(data)
|
91
110
|
|
92
|
-
@unknown_attributes =
|
111
|
+
@unknown_attributes = data.reject { |key, _| self.class.attributes.key?(key.to_sym) }.freeze
|
93
112
|
|
94
|
-
|
95
|
-
|
96
|
-
deserialize_attributes!(fields)
|
113
|
+
validate_data!(data)
|
114
|
+
deserialize_attributes!(data)
|
97
115
|
end
|
98
116
|
|
99
117
|
# Serializes the DTO to a Ruby hash.
|
@@ -101,7 +119,6 @@ module Castkit
|
|
101
119
|
# @param visited [Set, nil] used to track circular references
|
102
120
|
# @return [Hash]
|
103
121
|
def to_hash(visited: nil)
|
104
|
-
serializer = self.class.serializer || Castkit::DefaultSerializer
|
105
122
|
serializer.call(self, visited: visited)
|
106
123
|
end
|
107
124
|
|
@@ -123,48 +140,16 @@ module Castkit
|
|
123
140
|
|
124
141
|
private
|
125
142
|
|
126
|
-
#
|
143
|
+
# Helper method to call Castkit::Contract::Validator on the provided input data.
|
127
144
|
#
|
128
145
|
# @param data [Hash]
|
129
|
-
# @raise [Castkit::
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
unknown_keys = data.keys.map(&:to_sym) - valid_keys
|
137
|
-
return if unknown_keys.empty?
|
138
|
-
|
139
|
-
handle_unknown_keys!(unknown_keys)
|
140
|
-
end
|
141
|
-
|
142
|
-
# Validates the `strict` and `allow_unknown` config flags and generates a warning if they are conflicting.
|
143
|
-
# - If strict == true, allow_unknown cannot be true.
|
144
|
-
# - If strict == false, allow_unknown can be true | false.
|
145
|
-
#
|
146
|
-
# @return [void]
|
147
|
-
def validate_access_config!
|
148
|
-
return unless self.class.strict && self.class.allow_unknown
|
149
|
-
|
150
|
-
Castkit.warning "⚠️ [Castkit] Both `strict` and `allow_unknown` are enabled, which can lead to " \
|
151
|
-
"conflicting behavior. `strict` is being disabled to respect `allow_unknown`."
|
152
|
-
end
|
153
|
-
|
154
|
-
# Handles unknown keys found during initialization.
|
155
|
-
#
|
156
|
-
# Behavior depends on the class-level configuration:
|
157
|
-
# - Raises a `Castkit::DataObjectError` if strict mode is enabled.
|
158
|
-
# - Logs a warning if `warn_on_unknown` is enabled and `allow_unknown` is false.
|
159
|
-
#
|
160
|
-
# @param unknown_keys [Array<Symbol>] List of unknown keys not declared as attributes or aliases.
|
161
|
-
# @raise [Castkit::DataObjectError] If `strict` mode is enabled and unknown keys are found.
|
162
|
-
# @return [void]
|
163
|
-
def handle_unknown_keys!(unknown_keys)
|
164
|
-
raise Castkit::DataObjectError, "Unknown attribute(s): #{unknown_keys.join(", ")}" if strict?
|
165
|
-
return unless self.class.warn_on_unknown
|
166
|
-
|
167
|
-
Castkit.warning "⚠️ [Castkit] Unknown attribute(s) ignored: #{unknown_keys.join(", ")}"
|
146
|
+
# @raise [Castkit::ContractError]
|
147
|
+
def validate_data!(data)
|
148
|
+
Castkit::Contract::Validator.call!(
|
149
|
+
self.class.attributes.values,
|
150
|
+
data,
|
151
|
+
**self.class.validation_rules
|
152
|
+
)
|
168
153
|
end
|
169
154
|
|
170
155
|
# Returns the serializer instance or default for this object.
|