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
data/lib/castkit/attribute.rb
CHANGED
@@ -1,23 +1,18 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "error"
|
4
|
-
require_relative "
|
5
|
-
require_relative "
|
6
|
-
require_relative "
|
7
|
-
require_relative "attribute_extensions/access"
|
8
|
-
require_relative "attribute_extensions/validation"
|
9
|
-
require_relative "attribute_extensions/serialization"
|
4
|
+
require_relative "ext/attribute/options"
|
5
|
+
require_relative "ext/attribute/access"
|
6
|
+
require_relative "ext/attribute/validation"
|
10
7
|
|
11
8
|
module Castkit
|
12
9
|
# Represents a typed attribute on a Castkit::DataObject.
|
13
10
|
#
|
14
11
|
# Provides casting, validation, access control, and serialization behavior.
|
15
12
|
class Attribute
|
16
|
-
include Castkit::
|
17
|
-
include Castkit::
|
18
|
-
include Castkit::
|
19
|
-
include Castkit::AttributeExtensions::Validation
|
20
|
-
include Castkit::AttributeExtensions::Serialization
|
13
|
+
include Castkit::Ext::Attribute::Options
|
14
|
+
include Castkit::Ext::Attribute::Access
|
15
|
+
include Castkit::Ext::Attribute::Validation
|
21
16
|
|
22
17
|
# @return [Symbol] the attribute name
|
23
18
|
attr_reader :field
|
@@ -106,19 +101,6 @@ module Castkit
|
|
106
101
|
end
|
107
102
|
end
|
108
103
|
|
109
|
-
# Validates the final value against a validator if required.
|
110
|
-
#
|
111
|
-
# @param value [Object]
|
112
|
-
# @param context [Symbol, String]
|
113
|
-
# @return [void]
|
114
|
-
def validate_value!(value, context:)
|
115
|
-
return if value.nil? && optional?
|
116
|
-
return if type.is_a?(Array) || dataobject?
|
117
|
-
|
118
|
-
validator = options[:validator] || Castkit.configuration.validator_for(type)
|
119
|
-
validator&.call(value, options: options, context: context)
|
120
|
-
end
|
121
|
-
|
122
104
|
# Raises a Castkit::AttributeError with optional context.
|
123
105
|
#
|
124
106
|
# @param message [String]
|
data/lib/castkit/castkit.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "configuration"
|
4
|
+
require_relative "inflector"
|
4
5
|
|
5
6
|
# Castkit is a lightweight, type-safe data object system for Ruby.
|
6
7
|
#
|
@@ -15,42 +16,89 @@ require_relative "configuration"
|
|
15
16
|
#
|
16
17
|
# user = UserDto.new(name: "Alice", age: 30)
|
17
18
|
# user.to_h #=> { name: "Alice", age: 30 }
|
19
|
+
#
|
20
|
+
# @see Castkit::Contract
|
21
|
+
# @see Castkit::DataObject
|
18
22
|
module Castkit
|
23
|
+
# Namespace used for registering DataObjects generated from contracts.
|
24
|
+
module DataObjects; end
|
25
|
+
|
26
|
+
# Namespace used for registering contracts generated from DataObjects.
|
27
|
+
module Contracts; end
|
28
|
+
|
19
29
|
class << self
|
20
30
|
# Yields the global configuration object for customization.
|
21
31
|
#
|
22
|
-
# @example
|
32
|
+
# @example Disabling array enforcement
|
23
33
|
# Castkit.configure do |config|
|
24
|
-
# config.
|
34
|
+
# config.enforce_typing = false
|
25
35
|
# end
|
26
36
|
#
|
27
|
-
# @yieldparam config [Castkit::Configuration]
|
37
|
+
# @yieldparam config [Castkit::Configuration] the mutable config object
|
28
38
|
# @return [void]
|
29
39
|
def configure
|
30
40
|
yield(configuration)
|
31
41
|
end
|
32
42
|
|
33
|
-
# Retrieves the global Castkit configuration.
|
43
|
+
# Retrieves the global Castkit configuration instance.
|
34
44
|
#
|
35
|
-
# @return [Castkit::Configuration] the configuration
|
45
|
+
# @return [Castkit::Configuration] the configuration object
|
36
46
|
def configuration
|
37
47
|
@configuration ||= Configuration.new
|
38
48
|
end
|
39
49
|
|
40
|
-
#
|
50
|
+
# Emits a warning to STDERR if `enable_warnings` is enabled in config.
|
41
51
|
#
|
42
|
-
# @param message [String]
|
52
|
+
# @param message [String] the warning message
|
43
53
|
# @return [void]
|
44
54
|
def warning(message)
|
45
55
|
warn message if configuration.enable_warnings
|
46
56
|
end
|
47
57
|
|
48
|
-
#
|
58
|
+
# Checks whether a given object is a subclass of Castkit::DataObject.
|
49
59
|
#
|
50
|
-
# @param obj [Object]
|
51
|
-
# @return [Boolean]
|
60
|
+
# @param obj [Object] the object to test
|
61
|
+
# @return [Boolean] true if obj is a Castkit::DataObject class
|
52
62
|
def dataobject?(obj)
|
53
63
|
obj.is_a?(Class) && obj.ancestors.include?(Castkit::DataObject)
|
54
64
|
end
|
65
|
+
|
66
|
+
# Returns a type caster lambda for the given type.
|
67
|
+
#
|
68
|
+
# Type casting performs both validation and deserialization on the provided value.
|
69
|
+
#
|
70
|
+
# @param type [Symbol] the registered type (e.g. :string)
|
71
|
+
# @return [Proc] a lambda that accepts a value and options and returns a casted result
|
72
|
+
def type_caster(type)
|
73
|
+
type_definition = configuration.fetch_type(type)
|
74
|
+
|
75
|
+
lambda do |value, validator: nil, options: {}, context: nil|
|
76
|
+
type_definition.class.cast!(value, validator: validator, options: options, context: context)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Returns a serializer lambda for the given type.
|
81
|
+
#
|
82
|
+
# @param type [Symbol] the registered type (e.g. :string)
|
83
|
+
# @return [Proc] a lambda that calls `.serialize` on the type
|
84
|
+
def type_serializer(type)
|
85
|
+
->(value) { configuration.fetch_type(type).serialize(value) }
|
86
|
+
end
|
87
|
+
|
88
|
+
# Returns a deserializer lambda for the given type.
|
89
|
+
#
|
90
|
+
# @param type [Symbol] the registered type (e.g. :string)
|
91
|
+
# @return [Proc] a lambda that calls `.deserialize` on the type
|
92
|
+
def type_deserializer(type)
|
93
|
+
->(value) { configuration.fetch_type(type).deserialize(value) }
|
94
|
+
end
|
95
|
+
|
96
|
+
# Returns a validator lambda for the given type.
|
97
|
+
#
|
98
|
+
# @param type [Symbol] the registered type (e.g. :string)
|
99
|
+
# @return [Proc] a lambda that calls `.validate!` on the type
|
100
|
+
def type_validator(type)
|
101
|
+
->(value) { configuration.fetch_type(type).validate!(value) }
|
102
|
+
end
|
55
103
|
end
|
56
104
|
end
|
@@ -1,39 +1,47 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "
|
3
|
+
require_relative "types"
|
4
4
|
|
5
5
|
module Castkit
|
6
6
|
# Configuration container for global Castkit settings.
|
7
7
|
#
|
8
|
-
# This includes
|
8
|
+
# This includes type registration, validation, and enforcement flags
|
9
|
+
# used throughout Castkit's attribute system.
|
9
10
|
class Configuration
|
10
|
-
# Default mapping of primitive
|
11
|
+
# Default mapping of primitive type definitions.
|
11
12
|
#
|
12
|
-
# @return [Hash
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
13
|
+
# @return [Hash{Symbol => Castkit::Types::Generic}]
|
14
|
+
DEFAULT_TYPES = {
|
15
|
+
array: Castkit::Types::Collection.new,
|
16
|
+
boolean: Castkit::Types::Boolean.new,
|
17
|
+
date: Castkit::Types::Date.new,
|
18
|
+
datetime: Castkit::Types::DateTime.new,
|
19
|
+
float: Castkit::Types::Float.new,
|
20
|
+
hash: Castkit::Types::Generic.new,
|
21
|
+
integer: Castkit::Types::Integer.new,
|
22
|
+
string: Castkit::Types::String.new
|
17
23
|
}.freeze
|
18
24
|
|
19
|
-
#
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
25
|
+
# Type aliases for primitive type definitions.
|
26
|
+
#
|
27
|
+
# @return [Hash{Symbol => Symbol}]
|
28
|
+
TYPE_ALIASES = {
|
29
|
+
collection: :array,
|
30
|
+
bool: :boolean,
|
31
|
+
int: :integer,
|
32
|
+
map: :hash,
|
33
|
+
number: :float,
|
34
|
+
str: :string,
|
35
|
+
timestamp: :datetime,
|
36
|
+
uuid: :string
|
37
|
+
}.freeze
|
29
38
|
|
30
|
-
#
|
31
|
-
|
32
|
-
attr_accessor :enforce_boolean_casting
|
39
|
+
# @return [Hash{Symbol => Castkit::Types::Generic}] registered types
|
40
|
+
attr_reader :types
|
33
41
|
|
34
|
-
# Whether to raise an error if
|
42
|
+
# Whether to raise an error if values should be validated before deserializing, e.g. true -> "true"
|
35
43
|
# @return [Boolean]
|
36
|
-
attr_accessor :
|
44
|
+
attr_accessor :enforce_typing
|
37
45
|
|
38
46
|
# Whether to raise an error if access mode is not recognized.
|
39
47
|
# @return [Boolean]
|
@@ -47,55 +55,94 @@ module Castkit
|
|
47
55
|
# @return [Boolean]
|
48
56
|
attr_accessor :enforce_array_options
|
49
57
|
|
50
|
-
# Whether to
|
58
|
+
# Whether to raise an error for unknown and invalid type definitions.
|
59
|
+
# @return [Boolean]
|
60
|
+
attr_accessor :raise_type_errors
|
61
|
+
|
62
|
+
# Whether to emit warnings when Castkit detects misconfigurations.
|
51
63
|
# @return [Boolean]
|
52
64
|
attr_accessor :enable_warnings
|
53
65
|
|
54
|
-
#
|
66
|
+
# Whether the strict flag is enabled by default for all DataObjects and Contracts.
|
67
|
+
# @return [Boolean]
|
68
|
+
attr_accessor :strict_by_default
|
69
|
+
|
70
|
+
# Initializes the configuration with default types and enforcement flags.
|
55
71
|
#
|
56
72
|
# @return [void]
|
57
73
|
def initialize
|
58
|
-
@
|
59
|
-
@
|
60
|
-
@enforce_known_primitive_type = true
|
61
|
-
@enforce_boolean_casting = true
|
62
|
-
@enforce_union_match = true
|
74
|
+
@types = DEFAULT_TYPES.dup
|
75
|
+
@enforce_typing = true
|
63
76
|
@enforce_attribute_access = true
|
64
77
|
@enforce_unwrapped_prefix = true
|
65
78
|
@enforce_array_options = true
|
79
|
+
@raise_type_errors = true
|
66
80
|
@enable_warnings = true
|
81
|
+
@strict_by_default = true
|
82
|
+
|
83
|
+
apply_type_aliases!
|
67
84
|
end
|
68
85
|
|
69
|
-
# Registers a
|
86
|
+
# Registers a new type definition.
|
70
87
|
#
|
71
|
-
# @param type [Symbol] the type
|
72
|
-
# @param
|
73
|
-
# @param override [Boolean] whether to
|
74
|
-
# @raise [Castkit::
|
88
|
+
# @param type [Symbol] the symbolic type name (e.g., :uuid)
|
89
|
+
# @param klass [Class<Castkit::Types::Generic>] the class to register
|
90
|
+
# @param override [Boolean] whether to allow overwriting existing registration
|
91
|
+
# @raise [Castkit::TypeError] if the type class is invalid or not a subclass of Generic
|
75
92
|
# @return [void]
|
76
|
-
def
|
77
|
-
|
93
|
+
def register_type(type, klass, aliases: [], override: false)
|
94
|
+
type = type.to_sym
|
95
|
+
return if types.key?(type) && !override
|
78
96
|
|
79
|
-
|
80
|
-
|
97
|
+
instance = klass.new
|
98
|
+
unless instance.is_a?(Castkit::Types::Generic)
|
99
|
+
raise Castkit::TypeError, "Expected subclass of Castkit::Types::Generic for `#{type}`"
|
81
100
|
end
|
82
101
|
|
83
|
-
|
102
|
+
types[type] = instance
|
103
|
+
|
104
|
+
Castkit::Core::AttributeTypes.define_type_dsl(type) if Castkit::Core::AttributeTypes.respond_to?(:define_type_dsl)
|
105
|
+
return unless aliases.any?
|
106
|
+
|
107
|
+
aliases.each { |alias_type| register_type(alias_type, klass, override: override) }
|
84
108
|
end
|
85
109
|
|
86
|
-
# Returns the
|
110
|
+
# Returns the type handler for a given type symbol.
|
87
111
|
#
|
88
112
|
# @param type [Symbol]
|
89
|
-
# @return [
|
90
|
-
|
91
|
-
|
113
|
+
# @return [Castkit::Types::Generic]
|
114
|
+
# @raise [Castkit::TypeError] if the type is not registered
|
115
|
+
def fetch_type(type)
|
116
|
+
@types.fetch(type.to_sym) do
|
117
|
+
raise Castkit::TypeError, "Unknown type `#{type.inspect}`" if raise_type_errors
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Returns whether a type is currently registered.
|
122
|
+
#
|
123
|
+
# @param type [Symbol]
|
124
|
+
# @return [Boolean]
|
125
|
+
def type_registered?(type)
|
126
|
+
@types.key?(type.to_sym)
|
92
127
|
end
|
93
128
|
|
94
|
-
#
|
129
|
+
# Restores the type registry to its default state.
|
95
130
|
#
|
96
131
|
# @return [void]
|
97
|
-
def
|
98
|
-
@
|
132
|
+
def reset_types!
|
133
|
+
@types = DEFAULT_TYPES.dup
|
134
|
+
apply_type_aliases!
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
# Registers aliases for primitive type definitions.
|
140
|
+
#
|
141
|
+
# @return [void]
|
142
|
+
def apply_type_aliases!
|
143
|
+
TYPE_ALIASES.each do |alias_key, canonical|
|
144
|
+
register_type(alias_key, DEFAULT_TYPES[canonical].class)
|
145
|
+
end
|
99
146
|
end
|
100
147
|
end
|
101
148
|
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castkit
|
4
|
+
module Contract
|
5
|
+
# Extension module that adds `.to_dataobject` and `.dataobject` support to Castkit contracts.
|
6
|
+
#
|
7
|
+
# This allows any contract to be dynamically converted into a Castkit::DataObject class,
|
8
|
+
# enabling reuse of validation schemas for serialization, coercion, or API response modeling.
|
9
|
+
#
|
10
|
+
# This module is automatically included by Castkit contract classes and is not
|
11
|
+
# intended to be used manually.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# contract = Castkit::Contract.build(:user) do
|
15
|
+
# string :id
|
16
|
+
# string :email
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# UserDto = contract.to_dataobject
|
20
|
+
# UserDto.new(id: "123", email: "a@example.com")
|
21
|
+
module DataObject
|
22
|
+
# Returns or builds a Castkit::DataObject from the current contract.
|
23
|
+
#
|
24
|
+
# Memoizes the result to avoid repeated regeneration.
|
25
|
+
#
|
26
|
+
# @example
|
27
|
+
# contract = Castkit::Contract.build(:user) do
|
28
|
+
# string :id
|
29
|
+
# string :name
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# dto_class = contract.dataobject
|
33
|
+
# dto = dto_class.new(id: "123", name: "Alice")
|
34
|
+
#
|
35
|
+
# @return [Class<Castkit::DataObject>] the generated DTO class
|
36
|
+
def dataobject
|
37
|
+
@dataobject ||= to_dataobject
|
38
|
+
end
|
39
|
+
|
40
|
+
# Constructs an ephemeral Castkit::DataObject class from the current contract.
|
41
|
+
#
|
42
|
+
# This creates a new anonymous class each time unless memoized via {#dataobject}.
|
43
|
+
#
|
44
|
+
# @example
|
45
|
+
# dto_class = contract.to_dataobject
|
46
|
+
#
|
47
|
+
# @return [Class<Castkit::DataObject>] the dynamically generated DTO
|
48
|
+
def to_dataobject
|
49
|
+
Class.new(Castkit::DataObject).tap do |klass|
|
50
|
+
attributes.each_value do |attr|
|
51
|
+
klass.attribute(attr.field, attr.type, **attr.options)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Alias for {#to_dataobject}
|
57
|
+
#
|
58
|
+
# @see #to_dataobject
|
59
|
+
alias to_dto to_dataobject
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,168 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../core/config"
|
4
|
+
require_relative "../core/attribute_types"
|
5
|
+
require_relative "../core/registerable"
|
6
|
+
require_relative "result"
|
7
|
+
|
8
|
+
module Castkit
|
9
|
+
module Contract
|
10
|
+
# Base class for all Castkit contracts.
|
11
|
+
#
|
12
|
+
# Castkit contracts define validation logic over a set of attributes using a DSL.
|
13
|
+
# You can either subclass this directly or use {Castkit::Contract.build} to generate
|
14
|
+
# ephemeral or reusable contract classes.
|
15
|
+
#
|
16
|
+
# @example Subclassing directly
|
17
|
+
# class MyContract < Castkit::Contract::Generic
|
18
|
+
# string :id
|
19
|
+
# integer :count, required: false
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# MyContract.validate!(id: "abc")
|
23
|
+
#
|
24
|
+
# @example Using Contract.build (preferred for dynamic generation)
|
25
|
+
# UserContract = Castkit::Contract.build(:user) do
|
26
|
+
# string :id
|
27
|
+
# string :email, required: false
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# UserContract.validate!(id: "123")
|
31
|
+
#
|
32
|
+
# @see Castkit::Contract.build
|
33
|
+
class Generic
|
34
|
+
extend Castkit::Core::Config
|
35
|
+
extend Castkit::Core::AttributeTypes
|
36
|
+
extend Castkit::Core::Registerable
|
37
|
+
|
38
|
+
ATTRIBUTE_OPTIONS = %i[
|
39
|
+
required aliases min max format of validator unwrapped prefix force_type
|
40
|
+
].freeze
|
41
|
+
|
42
|
+
class << self
|
43
|
+
# Registers the current class under `Castkit::Contracts`.
|
44
|
+
#
|
45
|
+
# @param as [String, Symbol, nil] The constant name to use (PascalCase). Defaults to the name used when building
|
46
|
+
# the contract. If no name was provided, an error is raised.
|
47
|
+
# @return [Class] the registered contract class
|
48
|
+
# @raise [Castkit::Error] If a name cannot be resolved.
|
49
|
+
def register!(as: nil)
|
50
|
+
super(namespace: :contracts, as: as || definition[:name])
|
51
|
+
end
|
52
|
+
|
53
|
+
# Defines an attribute for the contract.
|
54
|
+
#
|
55
|
+
# Only a subset of options is allowed inside a contract.
|
56
|
+
#
|
57
|
+
# @param field [Symbol] the field name
|
58
|
+
# @param type [Symbol, Class, Array] the type or union of types
|
59
|
+
# @param options [Hash] allowed options like :required or :validator
|
60
|
+
# @return [void]
|
61
|
+
def attribute(field, type, **options)
|
62
|
+
options = options.slice(*ATTRIBUTE_OPTIONS)
|
63
|
+
attributes[field] = Castkit::Attribute.new(field, type, **options)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Validates input against the contract and returns a Result.
|
67
|
+
#
|
68
|
+
# @param input [Hash]
|
69
|
+
# @return [Castkit::Contract::Result]
|
70
|
+
def validate(input)
|
71
|
+
validate!(input)
|
72
|
+
Castkit::Contract::Result.new(definition[:name].to_s, input)
|
73
|
+
rescue Castkit::ContractError => e
|
74
|
+
Castkit::Contract::Result.new(definition[:name].to_s, input, errors: e.errors)
|
75
|
+
end
|
76
|
+
|
77
|
+
# Validates input and raises on failure.
|
78
|
+
#
|
79
|
+
# @param input [Hash]
|
80
|
+
# @raise [Castkit::ContractError]
|
81
|
+
# @return [void]
|
82
|
+
def validate!(input)
|
83
|
+
Castkit::Contract::Validator.call!(attributes.values, input, **validation_rules)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Returns internal contract metadata.
|
87
|
+
#
|
88
|
+
# @return [Hash]
|
89
|
+
def definition
|
90
|
+
@definition ||= {
|
91
|
+
name: :ephemeral_contract,
|
92
|
+
attributes: {}
|
93
|
+
}
|
94
|
+
end
|
95
|
+
|
96
|
+
# Returns the defined attributes.
|
97
|
+
#
|
98
|
+
# @return [Hash{Symbol => Castkit::Attribute}]
|
99
|
+
def attributes
|
100
|
+
definition[:attributes]
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
# Defines the contract from a source or block.
|
106
|
+
#
|
107
|
+
# @param name [Symbol, String]
|
108
|
+
# @param source [Castkit::DataObject, nil]
|
109
|
+
# @param block [Proc, nil]
|
110
|
+
# @return [Hash]
|
111
|
+
def define(name = :ephemeral_contract, source = nil, validation_rules: {}, &block)
|
112
|
+
validate_definition!(source, &block)
|
113
|
+
|
114
|
+
if source
|
115
|
+
define_from_source(name, source)
|
116
|
+
else
|
117
|
+
define_from_block(name, &block)
|
118
|
+
end
|
119
|
+
|
120
|
+
validation_rules.each { |k, v| self.validation_rules[k] = v }
|
121
|
+
attributes
|
122
|
+
end
|
123
|
+
|
124
|
+
# Copies attributes from a DataObject.
|
125
|
+
#
|
126
|
+
# @param name [Symbol, String]
|
127
|
+
# @param source [Castkit::DataObject]
|
128
|
+
# @return [void]
|
129
|
+
def define_from_source(name, source)
|
130
|
+
source_attributes = source.attributes.dup
|
131
|
+
|
132
|
+
@definition = {
|
133
|
+
name: name,
|
134
|
+
attributes: source_attributes.transform_values do |attr|
|
135
|
+
Castkit::Attribute.new(attr.field, attr.type, **attr.options.slice(*ATTRIBUTE_OPTIONS))
|
136
|
+
end
|
137
|
+
}
|
138
|
+
end
|
139
|
+
|
140
|
+
# Executes DSL block in the contract context.
|
141
|
+
#
|
142
|
+
# @param name [Symbol, String]
|
143
|
+
# @yield [block]
|
144
|
+
# @return [void]
|
145
|
+
def define_from_block(name, &block)
|
146
|
+
definition[:name] = name
|
147
|
+
|
148
|
+
@__castkit_contract_dsl = true
|
149
|
+
instance_eval(&block)
|
150
|
+
ensure
|
151
|
+
@__castkit_contract_dsl = false
|
152
|
+
end
|
153
|
+
|
154
|
+
# Ensures a valid contract definition input.
|
155
|
+
#
|
156
|
+
# @param source [Object, nil]
|
157
|
+
# @raise [Castkit::ContractError]
|
158
|
+
# @return [void]
|
159
|
+
def validate_definition!(source)
|
160
|
+
raise Castkit::ContractError, "Received both source and block" if source && block_given?
|
161
|
+
return if block_given? || Castkit.dataobject?(source)
|
162
|
+
|
163
|
+
raise Castkit::ContractError, "Expected a Castkit::DataObject or contract block"
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castkit
|
4
|
+
module Contract
|
5
|
+
# Represents the result of a contract validation.
|
6
|
+
#
|
7
|
+
# Provides access to the validation outcome, including whether it succeeded or failed,
|
8
|
+
# and includes the full list of errors if any.
|
9
|
+
class Result
|
10
|
+
# @return [Symbol] the name of the contract
|
11
|
+
attr_reader :contract
|
12
|
+
|
13
|
+
# @return [Hash{Symbol => Object}] the validated input
|
14
|
+
attr_reader :input
|
15
|
+
|
16
|
+
# @return [Hash{Symbol => Object}] the validation error hash
|
17
|
+
attr_reader :errors
|
18
|
+
|
19
|
+
# Initializes a new result object.
|
20
|
+
#
|
21
|
+
# @param contract [Symbol, String] the name of the contract
|
22
|
+
# @param input [Hash{Symbol => Object}] the validated input
|
23
|
+
# @param errors [Hash{Symbol => Object}] the validation errors
|
24
|
+
def initialize(contract, input, errors: {})
|
25
|
+
@contract = contract.to_sym.freeze
|
26
|
+
@input = input.freeze
|
27
|
+
@errors = errors.freeze
|
28
|
+
end
|
29
|
+
|
30
|
+
# A debug-friendly representation of the validation result.
|
31
|
+
#
|
32
|
+
# @return [String]
|
33
|
+
def inspect
|
34
|
+
"#<#{self.class.name} contract=#{contract.inspect} success=#{success?} errors=#{errors.inspect}>"
|
35
|
+
end
|
36
|
+
|
37
|
+
# Whether the validation passed with no errors.
|
38
|
+
#
|
39
|
+
# @return [Boolean]
|
40
|
+
def success?
|
41
|
+
errors.empty?
|
42
|
+
end
|
43
|
+
|
44
|
+
# Whether the validation failed with one or more errors.
|
45
|
+
#
|
46
|
+
# @return [Boolean]
|
47
|
+
def failure?
|
48
|
+
!success?
|
49
|
+
end
|
50
|
+
|
51
|
+
# A readable string representation of the validation result.
|
52
|
+
#
|
53
|
+
# @return [String]
|
54
|
+
def to_s
|
55
|
+
return "[Castkit] Contract validation passed for #{contract.inspect}" if success?
|
56
|
+
|
57
|
+
parsed_errors = errors.map { |k, v| " #{k}: #{v.inspect}" }.join("\n")
|
58
|
+
"[Castkit] Contract validation failed for #{contract.inspect}:\n#{parsed_errors}"
|
59
|
+
end
|
60
|
+
|
61
|
+
# @return [Hash{Symbol => Object}] the input validation and error hash
|
62
|
+
def to_hash
|
63
|
+
@to_hash ||= {
|
64
|
+
contract: contract,
|
65
|
+
input: input,
|
66
|
+
errors: errors
|
67
|
+
}.freeze
|
68
|
+
end
|
69
|
+
|
70
|
+
# @return [Hash{Symbol => Object}] the input and validation error hash
|
71
|
+
alias to_h to_hash
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|