castkit 0.1.2 → 0.3.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.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +195 -219
  3. data/CHANGELOG.md +42 -0
  4. data/README.md +744 -83
  5. data/castkit.gemspec +1 -0
  6. data/lib/castkit/attribute.rb +6 -24
  7. data/lib/castkit/castkit.rb +61 -10
  8. data/lib/castkit/cli/generate.rb +98 -0
  9. data/lib/castkit/cli/list.rb +200 -0
  10. data/lib/castkit/cli/main.rb +43 -0
  11. data/lib/castkit/cli.rb +24 -0
  12. data/lib/castkit/configuration.rb +116 -46
  13. data/lib/castkit/contract/base.rb +168 -0
  14. data/lib/castkit/contract/data_object.rb +62 -0
  15. data/lib/castkit/contract/result.rb +74 -0
  16. data/lib/castkit/contract/validator.rb +248 -0
  17. data/lib/castkit/contract.rb +67 -0
  18. data/lib/castkit/{data_object_extensions → core}/attribute_types.rb +21 -7
  19. data/lib/castkit/{data_object_extensions → core}/attributes.rb +8 -3
  20. data/lib/castkit/core/config.rb +74 -0
  21. data/lib/castkit/core/registerable.rb +59 -0
  22. data/lib/castkit/data_object.rb +56 -67
  23. data/lib/castkit/error.rb +15 -3
  24. data/lib/castkit/ext/attribute/access.rb +67 -0
  25. data/lib/castkit/ext/attribute/error_handling.rb +63 -0
  26. data/lib/castkit/ext/attribute/options.rb +142 -0
  27. data/lib/castkit/ext/attribute/validation.rb +85 -0
  28. data/lib/castkit/ext/data_object/contract.rb +96 -0
  29. data/lib/castkit/ext/data_object/deserialization.rb +167 -0
  30. data/lib/castkit/ext/data_object/plugins.rb +86 -0
  31. data/lib/castkit/ext/data_object/serialization.rb +61 -0
  32. data/lib/castkit/inflector.rb +47 -0
  33. data/lib/castkit/plugins.rb +82 -0
  34. data/lib/castkit/serializers/base.rb +94 -0
  35. data/lib/castkit/serializers/default_serializer.rb +156 -0
  36. data/lib/castkit/types/base.rb +122 -0
  37. data/lib/castkit/types/boolean.rb +47 -0
  38. data/lib/castkit/types/collection.rb +35 -0
  39. data/lib/castkit/types/date.rb +34 -0
  40. data/lib/castkit/types/date_time.rb +34 -0
  41. data/lib/castkit/types/float.rb +46 -0
  42. data/lib/castkit/types/integer.rb +46 -0
  43. data/lib/castkit/types/string.rb +44 -0
  44. data/lib/castkit/types.rb +15 -0
  45. data/lib/castkit/validators/base.rb +59 -0
  46. data/lib/castkit/validators/boolean_validator.rb +39 -0
  47. data/lib/castkit/validators/collection_validator.rb +29 -0
  48. data/lib/castkit/validators/float_validator.rb +31 -0
  49. data/lib/castkit/validators/integer_validator.rb +31 -0
  50. data/lib/castkit/validators/numeric_validator.rb +2 -2
  51. data/lib/castkit/validators/string_validator.rb +3 -4
  52. data/lib/castkit/version.rb +1 -1
  53. data/lib/castkit.rb +2 -0
  54. data/lib/generators/base.rb +97 -0
  55. data/lib/generators/contract.rb +68 -0
  56. data/lib/generators/data_object.rb +48 -0
  57. data/lib/generators/plugin.rb +25 -0
  58. data/lib/generators/serializer.rb +28 -0
  59. data/lib/generators/templates/contract.rb.tt +24 -0
  60. data/lib/generators/templates/contract_spec.rb.tt +76 -0
  61. data/lib/generators/templates/data_object.rb.tt +15 -0
  62. data/lib/generators/templates/data_object_spec.rb.tt +36 -0
  63. data/lib/generators/templates/plugin.rb.tt +37 -0
  64. data/lib/generators/templates/plugin_spec.rb.tt +18 -0
  65. data/lib/generators/templates/serializer.rb.tt +24 -0
  66. data/lib/generators/templates/serializer_spec.rb.tt +14 -0
  67. data/lib/generators/templates/type.rb.tt +55 -0
  68. data/lib/generators/templates/type_spec.rb.tt +42 -0
  69. data/lib/generators/templates/validator.rb.tt +26 -0
  70. data/lib/generators/templates/validator_spec.rb.tt +23 -0
  71. data/lib/generators/type.rb +29 -0
  72. data/lib/generators/validator.rb +41 -0
  73. metadata +74 -15
  74. data/lib/castkit/attribute_extensions/access.rb +0 -65
  75. data/lib/castkit/attribute_extensions/casting.rb +0 -147
  76. data/lib/castkit/attribute_extensions/error_handling.rb +0 -83
  77. data/lib/castkit/attribute_extensions/options.rb +0 -131
  78. data/lib/castkit/attribute_extensions/serialization.rb +0 -89
  79. data/lib/castkit/attribute_extensions/validation.rb +0 -72
  80. data/lib/castkit/data_object_extensions/config.rb +0 -113
  81. data/lib/castkit/data_object_extensions/deserialization.rb +0 -110
  82. data/lib/castkit/default_serializer.rb +0 -123
  83. data/lib/castkit/serializer.rb +0 -92
  84. 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/base"
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::Base>]
35
+ def build(name = nil, **validation_rules, &block)
36
+ klass = Class.new(Castkit::Contract::Base)
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::Base>]
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::Base)
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 DataObjectExtensions
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 DataObjectExtensions
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
- casted = attribute.load(value, context: field)
131
- instance_variable_set("@#{field}", casted)
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
@@ -3,11 +3,16 @@
3
3
  require "json"
4
4
  require_relative "error"
5
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"
6
+ require_relative "serializers/default_serializer"
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/plugins"
15
+ require_relative "ext/data_object/serialization"
11
16
 
12
17
  module Castkit
13
18
  # Base class for defining declarative, typed data transfer objects (DTOs).
@@ -25,21 +30,42 @@ module Castkit
25
30
  # user = UserDto.new(name: "Alice", age: 30)
26
31
  # user.to_json #=> '{"name":"Alice","age":30}'
27
32
  class DataObject
28
- extend Castkit::DataObjectExtensions::Attributes
29
- extend Castkit::DataObjectExtensions::AttributeTypes
33
+ extend Castkit::Core::Config
34
+ extend Castkit::Core::Attributes
35
+ extend Castkit::Core::AttributeTypes
36
+ extend Castkit::Core::Registerable
37
+ extend Castkit::Ext::DataObject::Contract
38
+ extend Castkit::Ext::DataObject::Plugins
30
39
 
31
- include Castkit::DataObjectExtensions::Config
32
- include Castkit::DataObjectExtensions::Deserialization
40
+ include Castkit::Ext::DataObject::Serialization
41
+ include Castkit::Ext::DataObject::Deserialization
33
42
 
34
43
  class << self
44
+ # Registers the current class under `Castkit::DataObjects`.
45
+ #
46
+ # @param as [String, Symbol, nil] The constant name to use (PascalCase). Defaults to class name or "Anonymous".
47
+ # @return [Class] the registered dataobject class
48
+ def register!(as: nil)
49
+ super(namespace: :dataobjects, as: as)
50
+ end
51
+
52
+ def build(&block)
53
+ klass = Class.new(self)
54
+ klass.class_eval(&block) if block_given?
55
+
56
+ klass
57
+ end
58
+
35
59
  # Gets or sets the serializer class to use for instances of this object.
36
60
  #
37
- # @param value [Class<Castkit::Serializer>, nil]
38
- # @return [Class<Castkit::Serializer>, nil]
39
- # @raise [ArgumentError] if value does not inherit from Castkit::Serializer
61
+ # @param value [Class<Castkit::Serializers::Base>, nil]
62
+ # @return [Class<Castkit::Serializers::Base>, nil]
63
+ # @raise [ArgumentError] if value does not inherit from Castkit::Serializers::Base
40
64
  def serializer(value = nil)
41
65
  if value
42
- raise ArgumentError, "Serializer must inherit from Castkit::Serializer" unless value < Castkit::Serializer
66
+ unless value < Castkit::Serializers::Base
67
+ raise ArgumentError, "Serializer must inherit from Castkit::Serializers::Base"
68
+ end
43
69
 
44
70
  @serializer = value
45
71
  else
@@ -80,20 +106,16 @@ module Castkit
80
106
 
81
107
  # Initializes the DTO from a hash of attributes.
82
108
  #
83
- # @param fields [Hash] raw input hash
109
+ # @param data [Hash] raw input hash
84
110
  # @raise [Castkit::DataObjectError] if strict mode is enabled and unknown keys are present
85
- def initialize(fields = {})
86
- @__raw = fields
87
-
88
- root = self.class.root
89
- fields = fields[root] if root && fields.key?(root)
90
- fields = unwrap_prefixed_fields!(fields)
111
+ def initialize(data = {})
112
+ @__raw = data.dup.freeze
113
+ data = unwrap_root(data)
91
114
 
92
- @unknown_attributes = fields.reject { |key, _| self.class.attributes.key?(key.to_sym) }
115
+ @unknown_attributes = data.reject { |key, _| self.class.attributes.key?(key.to_sym) }.freeze
93
116
 
94
- validate_access_config!
95
- validate_keys!(fields)
96
- deserialize_attributes!(fields)
117
+ validate_data!(data)
118
+ deserialize_attributes!(data)
97
119
  end
98
120
 
99
121
  # Serializes the DTO to a Ruby hash.
@@ -101,7 +123,6 @@ module Castkit
101
123
  # @param visited [Set, nil] used to track circular references
102
124
  # @return [Hash]
103
125
  def to_hash(visited: nil)
104
- serializer = self.class.serializer || Castkit::DefaultSerializer
105
126
  serializer.call(self, visited: visited)
106
127
  end
107
128
 
@@ -123,55 +144,23 @@ module Castkit
123
144
 
124
145
  private
125
146
 
126
- # Validates that the input only contains known keys unless configured otherwise.
147
+ # Helper method to call Castkit::Contract::Validator on the provided input data.
127
148
  #
128
149
  # @param data [Hash]
129
- # @raise [Castkit::DataObjectError] in strict mode if unknown keys are present
130
- # @return [void]
131
- def validate_keys!(data)
132
- valid_keys = self.class.attributes.flat_map do |_, attr|
133
- [attr.key] + attr.options[:aliases]
134
- end.map(&:to_sym).uniq
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(", ")}"
150
+ # @raise [Castkit::ContractError]
151
+ def validate_data!(data)
152
+ Castkit::Contract::Validator.call!(
153
+ self.class.attributes.values,
154
+ data,
155
+ **self.class.validation_rules
156
+ )
168
157
  end
169
158
 
170
159
  # Returns the serializer instance or default for this object.
171
160
  #
172
- # @return [Class<Castkit::Serializer>]
161
+ # @return [Class<Castkit::Serializers::Base>]
173
162
  def serializer
174
- @serializer ||= self.class.serializer || Castkit::DefaultSerializer
163
+ @serializer ||= self.class.serializer || Castkit::Serializers::DefaultSerializer
175
164
  end
176
165
 
177
166
  # Returns false if self.class.allow_unknown == true, otherwise the value of self.class.strict.