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.
- checksums.yaml +4 -4
- data/.rspec_status +195 -219
- data/CHANGELOG.md +42 -0
- data/README.md +744 -83
- data/castkit.gemspec +1 -0
- data/lib/castkit/attribute.rb +6 -24
- data/lib/castkit/castkit.rb +61 -10
- data/lib/castkit/cli/generate.rb +98 -0
- data/lib/castkit/cli/list.rb +200 -0
- data/lib/castkit/cli/main.rb +43 -0
- data/lib/castkit/cli.rb +24 -0
- data/lib/castkit/configuration.rb +116 -46
- data/lib/castkit/contract/base.rb +168 -0
- data/lib/castkit/contract/data_object.rb +62 -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 +56 -67
- 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/plugins.rb +86 -0
- data/lib/castkit/ext/data_object/serialization.rb +61 -0
- data/lib/castkit/inflector.rb +47 -0
- data/lib/castkit/plugins.rb +82 -0
- data/lib/castkit/serializers/base.rb +94 -0
- data/lib/castkit/serializers/default_serializer.rb +156 -0
- data/lib/castkit/types/base.rb +122 -0
- data/lib/castkit/types/boolean.rb +47 -0
- data/lib/castkit/types/collection.rb +35 -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/integer.rb +46 -0
- data/lib/castkit/types/string.rb +44 -0
- data/lib/castkit/types.rb +15 -0
- data/lib/castkit/validators/base.rb +59 -0
- data/lib/castkit/validators/boolean_validator.rb +39 -0
- data/lib/castkit/validators/collection_validator.rb +29 -0
- data/lib/castkit/validators/float_validator.rb +31 -0
- data/lib/castkit/validators/integer_validator.rb +31 -0
- data/lib/castkit/validators/numeric_validator.rb +2 -2
- data/lib/castkit/validators/string_validator.rb +3 -4
- data/lib/castkit/version.rb +1 -1
- data/lib/castkit.rb +2 -0
- data/lib/generators/base.rb +97 -0
- data/lib/generators/contract.rb +68 -0
- data/lib/generators/data_object.rb +48 -0
- data/lib/generators/plugin.rb +25 -0
- data/lib/generators/serializer.rb +28 -0
- data/lib/generators/templates/contract.rb.tt +24 -0
- data/lib/generators/templates/contract_spec.rb.tt +76 -0
- data/lib/generators/templates/data_object.rb.tt +15 -0
- data/lib/generators/templates/data_object_spec.rb.tt +36 -0
- data/lib/generators/templates/plugin.rb.tt +37 -0
- data/lib/generators/templates/plugin_spec.rb.tt +18 -0
- data/lib/generators/templates/serializer.rb.tt +24 -0
- data/lib/generators/templates/serializer_spec.rb.tt +14 -0
- data/lib/generators/templates/type.rb.tt +55 -0
- data/lib/generators/templates/type_spec.rb.tt +42 -0
- data/lib/generators/templates/validator.rb.tt +26 -0
- data/lib/generators/templates/validator_spec.rb.tt +23 -0
- data/lib/generators/type.rb +29 -0
- data/lib/generators/validator.rb +41 -0
- metadata +74 -15
- 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/default_serializer.rb +0 -123
- data/lib/castkit/serializer.rb +0 -92
- data/lib/castkit/validators.rb +0 -4
data/lib/castkit/error.rb
CHANGED
@@ -9,13 +9,15 @@ module Castkit
|
|
9
9
|
# Initializes a Castkit error.
|
10
10
|
#
|
11
11
|
# @param msg [String] the error message
|
12
|
-
# @param context [Object, nil] optional data object or hash for context
|
12
|
+
# @param context [Object, String, nil] optional data object or hash for context
|
13
13
|
def initialize(msg, context: nil)
|
14
14
|
super(msg)
|
15
15
|
@context = context
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
19
|
+
class TypeError < Error; end
|
20
|
+
|
19
21
|
# Raised for issues related to Castkit::DataObject initialization or usage.
|
20
22
|
class DataObjectError < Error; end
|
21
23
|
|
@@ -23,9 +25,9 @@ module Castkit
|
|
23
25
|
class AttributeError < Error
|
24
26
|
# Returns the field name related to the error, if available.
|
25
27
|
#
|
26
|
-
# @return [Symbol]
|
28
|
+
# @return [Symbol, nil]
|
27
29
|
def field
|
28
|
-
context.is_a?(Hash) ? context[:field] : context ||
|
30
|
+
context.is_a?(Hash) ? context[:field] : context || nil
|
29
31
|
end
|
30
32
|
|
31
33
|
# Formats the error message with field info if available.
|
@@ -39,4 +41,14 @@ module Castkit
|
|
39
41
|
|
40
42
|
# Raised during serialization if an object fails to serialize properly.
|
41
43
|
class SerializationError < Error; end
|
44
|
+
|
45
|
+
# Raised during contract validation.
|
46
|
+
class ContractError < Error
|
47
|
+
attr_reader :errors
|
48
|
+
|
49
|
+
def initialize(msg, context: nil, errors: nil)
|
50
|
+
super(msg, context: context)
|
51
|
+
@errors = errors || {}
|
52
|
+
end
|
53
|
+
end
|
42
54
|
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castkit
|
4
|
+
module Ext
|
5
|
+
module Attribute
|
6
|
+
# Provides access control helpers for attributes.
|
7
|
+
#
|
8
|
+
# These helpers determine whether an attribute is readable, writeable,
|
9
|
+
# or should be included during serialization/deserialization based on the
|
10
|
+
# configured `:access` and `:ignore` options.
|
11
|
+
module Access
|
12
|
+
# Returns the normalized access modes for the attribute (e.g., [:read, :write]).
|
13
|
+
#
|
14
|
+
# @return [Array<Symbol>] list of access symbols
|
15
|
+
def access
|
16
|
+
Array(options[:access]).map(&:to_sym)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Whether the attribute should be included during serialization.
|
20
|
+
#
|
21
|
+
# @return [Boolean]
|
22
|
+
def readable?
|
23
|
+
access.include?(:read)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Whether the attribute should be accepted during deserialization.
|
27
|
+
#
|
28
|
+
# Composite attributes are excluded from writeability.
|
29
|
+
#
|
30
|
+
# @return [Boolean]
|
31
|
+
def writeable?
|
32
|
+
access.include?(:write) && !composite?
|
33
|
+
end
|
34
|
+
|
35
|
+
# Whether the attribute is both readable and writeable.
|
36
|
+
#
|
37
|
+
# @return [Boolean]
|
38
|
+
def full_access?
|
39
|
+
readable? && writeable?
|
40
|
+
end
|
41
|
+
|
42
|
+
# Whether the attribute should be skipped during serialization.
|
43
|
+
#
|
44
|
+
# This is true if it's not readable or is marked as ignored.
|
45
|
+
#
|
46
|
+
# @return [Boolean]
|
47
|
+
def skip_serialization?
|
48
|
+
!readable? || ignore?
|
49
|
+
end
|
50
|
+
|
51
|
+
# Whether the attribute should be skipped during deserialization.
|
52
|
+
#
|
53
|
+
# @return [Boolean]
|
54
|
+
def skip_deserialization?
|
55
|
+
!writeable?
|
56
|
+
end
|
57
|
+
|
58
|
+
# Whether the attribute is ignored completely.
|
59
|
+
#
|
60
|
+
# @return [Boolean]
|
61
|
+
def ignore?
|
62
|
+
options[:ignore]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castkit
|
4
|
+
module Ext
|
5
|
+
module Attribute
|
6
|
+
# Provides centralized handling of attribute casting and validation errors.
|
7
|
+
#
|
8
|
+
# The behavior of each error is controlled by configuration flags in `Castkit.configuration`.
|
9
|
+
module ErrorHandling
|
10
|
+
# Maps known error types to their handling behavior.
|
11
|
+
#
|
12
|
+
# Each entry includes:
|
13
|
+
# - `:config` – the config flag that determines enforcement
|
14
|
+
# - `:message` – a lambda that generates an error message
|
15
|
+
# - `:error` – the error class to raise
|
16
|
+
#
|
17
|
+
# @return [Hash{Symbol => Hash}]
|
18
|
+
ERROR_OPTIONS = {
|
19
|
+
access: {
|
20
|
+
config: :enforce_attribute_access,
|
21
|
+
message: ->(_attr, mode:) { "invalid access mode `#{mode}`" },
|
22
|
+
error: Castkit::AttributeError
|
23
|
+
},
|
24
|
+
unwrapped: {
|
25
|
+
config: :enforce_unwrapped_prefix,
|
26
|
+
message: ->(*_) { "prefix can only be used with unwrapped attribute" },
|
27
|
+
error: Castkit::AttributeError
|
28
|
+
},
|
29
|
+
array_options: {
|
30
|
+
config: :enforce_array_options,
|
31
|
+
message: ->(*_) { "array attribute must specify `of:` type" },
|
32
|
+
error: Castkit::AttributeError
|
33
|
+
}
|
34
|
+
}.freeze
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# Handles a validation or casting error based on the provided error key and context.
|
39
|
+
#
|
40
|
+
# If the corresponding configuration flag is enabled, an exception is raised.
|
41
|
+
# Otherwise, a warning is logged and the method returns `nil`.
|
42
|
+
#
|
43
|
+
# @param key [Symbol] the type of error (must match a key in ERROR_OPTIONS)
|
44
|
+
# @param kwargs [Hash] additional values passed to the message lambda
|
45
|
+
# @option kwargs [Symbol] :context (optional) the attribute context (e.g., field name)
|
46
|
+
# @return [nil]
|
47
|
+
# @raise [Castkit::AttributeError] if enforcement is enabled for the given error type
|
48
|
+
def handle_error(key, **kwargs)
|
49
|
+
config_key = ERROR_OPTIONS.dig(key, :config)
|
50
|
+
message_fn = ERROR_OPTIONS.dig(key, :message)
|
51
|
+
error_class = ERROR_OPTIONS.dig(key, :error) || Castkit::Error
|
52
|
+
|
53
|
+
context = kwargs.delete(:context)
|
54
|
+
message = message_fn.call(self, **kwargs)
|
55
|
+
raise error_class.new(message, context: context) if Castkit.configuration.public_send(config_key)
|
56
|
+
|
57
|
+
Castkit.warning "[Castkit] #{message}"
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../data_object"
|
4
|
+
|
5
|
+
module Castkit
|
6
|
+
module Ext
|
7
|
+
module Attribute
|
8
|
+
# Provides access to normalized attribute options and helper predicates.
|
9
|
+
#
|
10
|
+
# These methods support Castkit attribute behavior such as default values,
|
11
|
+
# key mapping, optionality, and structural roles (e.g. composite or unwrapped).
|
12
|
+
module Options
|
13
|
+
# Default options for attributes.
|
14
|
+
#
|
15
|
+
# @return [Hash{Symbol => Object}]
|
16
|
+
DEFAULT_OPTIONS = {
|
17
|
+
required: true,
|
18
|
+
ignore_nil: false,
|
19
|
+
ignore_blank: false,
|
20
|
+
ignore: false,
|
21
|
+
composite: false,
|
22
|
+
transient: false,
|
23
|
+
unwrapped: false,
|
24
|
+
prefix: nil,
|
25
|
+
access: %i[read write],
|
26
|
+
force_type: !Castkit.configuration.enforce_typing
|
27
|
+
}.freeze
|
28
|
+
|
29
|
+
# Returns the default value for the attribute.
|
30
|
+
#
|
31
|
+
# If the default is callable, it is invoked.
|
32
|
+
#
|
33
|
+
# @return [Object]
|
34
|
+
def default
|
35
|
+
val = @default
|
36
|
+
val.respond_to?(:call) ? val.call : val
|
37
|
+
end
|
38
|
+
|
39
|
+
# Returns the serialization/deserialization key.
|
40
|
+
#
|
41
|
+
# Falls back to the field name if `:key` is not specified.
|
42
|
+
#
|
43
|
+
# @return [Symbol, String]
|
44
|
+
def key
|
45
|
+
options[:key] || field
|
46
|
+
end
|
47
|
+
|
48
|
+
# Returns the key path for accessing nested keys.
|
49
|
+
#
|
50
|
+
# Optionally includes alias key paths if `with_aliases` is true.
|
51
|
+
#
|
52
|
+
# @param with_aliases [Boolean]
|
53
|
+
# @return [Array<Array<Symbol>>] nested key paths
|
54
|
+
def key_path(with_aliases: false)
|
55
|
+
path = key.to_s.split(".").map(&:to_sym) || []
|
56
|
+
return path unless with_aliases
|
57
|
+
|
58
|
+
[path] + alias_paths
|
59
|
+
end
|
60
|
+
|
61
|
+
# Returns all alias key paths as arrays of symbols.
|
62
|
+
#
|
63
|
+
# @return [Array<Array<Symbol>>]
|
64
|
+
def alias_paths
|
65
|
+
options[:aliases].map { |a| a.to_s.split(".").map(&:to_sym) }
|
66
|
+
end
|
67
|
+
|
68
|
+
# Whether the attribute is required for object construction.
|
69
|
+
#
|
70
|
+
# @return [Boolean]
|
71
|
+
def required?
|
72
|
+
options[:required]
|
73
|
+
end
|
74
|
+
|
75
|
+
# Whether the attribute is optional.
|
76
|
+
#
|
77
|
+
# @return [Boolean]
|
78
|
+
def optional?
|
79
|
+
!required?
|
80
|
+
end
|
81
|
+
|
82
|
+
# Whether to ignore `nil` values during serialization.
|
83
|
+
#
|
84
|
+
# @return [Boolean]
|
85
|
+
def ignore_nil?
|
86
|
+
options[:ignore_nil]
|
87
|
+
end
|
88
|
+
|
89
|
+
# Whether to ignore blank values (`[]`, `{}`, empty strings) during serialization.
|
90
|
+
#
|
91
|
+
# @return [Boolean]
|
92
|
+
def ignore_blank?
|
93
|
+
options[:ignore_blank]
|
94
|
+
end
|
95
|
+
|
96
|
+
# Whether the attribute is a nested Castkit::DataObject.
|
97
|
+
#
|
98
|
+
# @return [Boolean]
|
99
|
+
def dataobject?
|
100
|
+
Castkit.dataobject?(type)
|
101
|
+
end
|
102
|
+
|
103
|
+
# Whether the attribute is a collection of Castkit::DataObjects.
|
104
|
+
#
|
105
|
+
# @return [Boolean]
|
106
|
+
def dataobject_collection?
|
107
|
+
type == :array && Castkit.dataobject?(options[:of])
|
108
|
+
end
|
109
|
+
|
110
|
+
# Whether the attribute is considered composite.
|
111
|
+
#
|
112
|
+
# @return [Boolean]
|
113
|
+
def composite?
|
114
|
+
options[:composite]
|
115
|
+
end
|
116
|
+
|
117
|
+
# Whether the attribute is considered transient (not exposed in serialized output).
|
118
|
+
#
|
119
|
+
# @return [Boolean]
|
120
|
+
def transient?
|
121
|
+
options[:transient]
|
122
|
+
end
|
123
|
+
|
124
|
+
# Whether the attribute is unwrapped into the parent object.
|
125
|
+
#
|
126
|
+
# Only applies to Castkit::DataObject types.
|
127
|
+
#
|
128
|
+
# @return [Boolean]
|
129
|
+
def unwrapped?
|
130
|
+
dataobject? && options[:unwrapped]
|
131
|
+
end
|
132
|
+
|
133
|
+
# Returns the prefix used for unwrapped attributes.
|
134
|
+
#
|
135
|
+
# @return [String, nil]
|
136
|
+
def prefix
|
137
|
+
options[:prefix]
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "error_handling"
|
4
|
+
require_relative "options"
|
5
|
+
|
6
|
+
module Castkit
|
7
|
+
module Ext
|
8
|
+
module Attribute
|
9
|
+
# Provides validation logic for attribute configuration.
|
10
|
+
#
|
11
|
+
# These checks are typically performed at attribute initialization to catch misconfigurations early.
|
12
|
+
module Validation
|
13
|
+
include Castkit::Ext::Attribute::ErrorHandling
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
# Runs all validation checks on the attribute definition.
|
18
|
+
#
|
19
|
+
# This includes:
|
20
|
+
# - Custom validator integrity
|
21
|
+
# - Access mode validity
|
22
|
+
# - Unwrapped prefix usage
|
23
|
+
# - Array `of:` type presence
|
24
|
+
#
|
25
|
+
# @return [void]
|
26
|
+
def validate!
|
27
|
+
validate_type!
|
28
|
+
validate_custom_validator!
|
29
|
+
validate_access!
|
30
|
+
validate_unwrapped_options!
|
31
|
+
validate_array_options!
|
32
|
+
end
|
33
|
+
|
34
|
+
def validate_type!
|
35
|
+
types ||= Array(type) # used to test single type and type unions.
|
36
|
+
|
37
|
+
types.each do |t|
|
38
|
+
next if Castkit.dataobject?(t) || Castkit.configuration.type_registered?(t)
|
39
|
+
|
40
|
+
raise_error!("Type is not registered, register with Castkit.configuration.register_type(:#{t})")
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Validates the presence and interface of a custom validator.
|
45
|
+
#
|
46
|
+
# @return [void]
|
47
|
+
# @raise [Castkit::AttributeError] if the validator is not callable
|
48
|
+
def validate_custom_validator!
|
49
|
+
return unless options[:validator]
|
50
|
+
return if options[:validator].respond_to?(:call)
|
51
|
+
|
52
|
+
raise_error!("Custom validator for `#{field}` must respond to `.call`")
|
53
|
+
end
|
54
|
+
|
55
|
+
# Validates that each declared access mode is valid.
|
56
|
+
#
|
57
|
+
# @return [void]
|
58
|
+
# @raise [Castkit::AttributeError] if any access mode is invalid and enforcement is enabled
|
59
|
+
def validate_access!
|
60
|
+
access.each do |mode|
|
61
|
+
next if Castkit::Ext::Attribute::Options::DEFAULT_OPTIONS[:access].include?(mode)
|
62
|
+
|
63
|
+
handle_error(:access, mode: mode, context: to_h)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Ensures prefix is only used with unwrapped attributes.
|
68
|
+
#
|
69
|
+
# @return [void]
|
70
|
+
# @raise [Castkit::AttributeError] if prefix is used without `unwrapped: true`
|
71
|
+
def validate_unwrapped_options!
|
72
|
+
handle_error(:unwrapped, context: to_h) if prefix && !unwrapped?
|
73
|
+
end
|
74
|
+
|
75
|
+
# Ensures `of:` is provided for array-typed attributes.
|
76
|
+
#
|
77
|
+
# @return [void]
|
78
|
+
# @raise [Castkit::AttributeError] if `of:` is missing for `type: :array`
|
79
|
+
def validate_array_options!
|
80
|
+
handle_error(:array_options, context: to_h) if type == :array && options[:of].nil?
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -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::Base>] 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
|