easy_talk 3.1.0 → 3.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/.rubocop.yml +15 -39
- data/.yardopts +13 -0
- data/CHANGELOG.md +164 -0
- data/README.md +442 -1529
- data/Rakefile +27 -0
- data/docs/.gitignore +1 -0
- data/docs/about.markdown +28 -8
- data/docs/getting-started.markdown +102 -0
- data/docs/index.markdown +51 -4
- data/docs/json_schema_compliance.md +169 -0
- data/docs/nested-models.markdown +216 -0
- data/docs/primitive-schema-rfc.md +894 -0
- data/docs/property-types.markdown +212 -0
- data/docs/schema-definition.markdown +180 -0
- data/lib/easy_talk/builders/base_builder.rb +6 -3
- data/lib/easy_talk/builders/boolean_builder.rb +2 -1
- data/lib/easy_talk/builders/collection_helpers.rb +4 -0
- data/lib/easy_talk/builders/composition_builder.rb +16 -13
- data/lib/easy_talk/builders/integer_builder.rb +2 -1
- data/lib/easy_talk/builders/null_builder.rb +4 -1
- data/lib/easy_talk/builders/number_builder.rb +4 -1
- data/lib/easy_talk/builders/object_builder.rb +109 -33
- data/lib/easy_talk/builders/registry.rb +182 -0
- data/lib/easy_talk/builders/string_builder.rb +3 -1
- data/lib/easy_talk/builders/temporal_builder.rb +7 -0
- data/lib/easy_talk/builders/tuple_builder.rb +89 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +19 -6
- data/lib/easy_talk/builders/union_builder.rb +5 -1
- data/lib/easy_talk/configuration.rb +47 -2
- data/lib/easy_talk/error_formatter/base.rb +100 -0
- data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
- data/lib/easy_talk/error_formatter/flat.rb +38 -0
- data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
- data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
- data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
- data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
- data/lib/easy_talk/error_formatter.rb +143 -0
- data/lib/easy_talk/errors.rb +3 -0
- data/lib/easy_talk/errors_helper.rb +66 -34
- data/lib/easy_talk/json_schema_equality.rb +46 -0
- data/lib/easy_talk/keywords.rb +0 -1
- data/lib/easy_talk/model.rb +148 -89
- data/lib/easy_talk/model_helper.rb +17 -0
- data/lib/easy_talk/naming_strategies.rb +24 -0
- data/lib/easy_talk/property.rb +23 -94
- data/lib/easy_talk/ref_helper.rb +33 -0
- data/lib/easy_talk/schema.rb +199 -0
- data/lib/easy_talk/schema_definition.rb +57 -5
- data/lib/easy_talk/schema_methods.rb +111 -0
- data/lib/easy_talk/sorbet_extension.rb +1 -0
- data/lib/easy_talk/tools/function_builder.rb +1 -1
- data/lib/easy_talk/type_introspection.rb +222 -0
- data/lib/easy_talk/types/base_composer.rb +2 -1
- data/lib/easy_talk/types/composer.rb +4 -0
- data/lib/easy_talk/types/tuple.rb +77 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +617 -0
- data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
- data/lib/easy_talk/validation_adapters/base.rb +156 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +45 -0
- data/lib/easy_talk/validation_adapters/registry.rb +87 -0
- data/lib/easy_talk/validation_builder.rb +29 -309
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +42 -0
- metadata +38 -7
- data/docs/404.html +0 -25
- data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
- data/easy_talk.gemspec +0 -39
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
module EasyTalk
|
|
4
5
|
# Helper module for generating consistent error messages
|
|
5
6
|
module ErrorHelper
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
6
9
|
def self.raise_constraint_error(property_name:, constraint_name:, expected:, got:)
|
|
7
10
|
message = "Error in property '#{property_name}': Constraint '#{constraint_name}' expects #{expected}, " \
|
|
8
11
|
"but received #{got.inspect} (#{got.class})."
|
|
@@ -27,7 +30,7 @@ module EasyTalk
|
|
|
27
30
|
if type_info.respond_to?(:type) && type_info.type.respond_to?(:raw_type)
|
|
28
31
|
type_info.type.raw_type
|
|
29
32
|
# special boolean handling
|
|
30
|
-
elsif type_info.try(:type)
|
|
33
|
+
elsif TypeIntrospection.boolean_type?(type_info.try(:type))
|
|
31
34
|
T::Boolean
|
|
32
35
|
elsif type_info.respond_to?(:type_parameter)
|
|
33
36
|
type_info.type_parameter
|
|
@@ -44,47 +47,77 @@ module EasyTalk
|
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
def self.validate_typed_array_values(property_name:, constraint_name:, type_info:, array_value:)
|
|
47
|
-
#
|
|
48
|
-
|
|
50
|
+
# Raise error if value is not an array but type expects one
|
|
51
|
+
unless array_value.is_a?(Array)
|
|
52
|
+
inner_type = extract_inner_type(type_info)
|
|
53
|
+
expected_desc = TypeIntrospection.boolean_type?(inner_type) ? 'Boolean (true or false)' : inner_type.to_s
|
|
54
|
+
raise_constraint_error(
|
|
55
|
+
property_name: property_name,
|
|
56
|
+
constraint_name: constraint_name,
|
|
57
|
+
expected: expected_desc,
|
|
58
|
+
got: array_value
|
|
59
|
+
)
|
|
60
|
+
end
|
|
49
61
|
|
|
50
|
-
# Extract the inner type from the array type definition
|
|
51
62
|
inner_type = extract_inner_type(type_info)
|
|
52
|
-
|
|
53
|
-
# Check each element of the array
|
|
54
63
|
array_value.each_with_index do |element, index|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
got: element
|
|
65
|
-
)
|
|
66
|
-
end
|
|
67
|
-
else
|
|
68
|
-
# For single types, just check against that type
|
|
69
|
-
next if [true, false].include?(element)
|
|
64
|
+
validate_array_element(
|
|
65
|
+
property_name: property_name,
|
|
66
|
+
constraint_name: constraint_name,
|
|
67
|
+
inner_type: inner_type,
|
|
68
|
+
element: element,
|
|
69
|
+
index: index
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
def self.validate_array_element(property_name:, constraint_name:, inner_type:, element:, index:)
|
|
75
|
+
if inner_type.is_a?(Array)
|
|
76
|
+
validate_union_element(property_name, constraint_name, inner_type, element, index)
|
|
77
|
+
else
|
|
78
|
+
validate_single_type_element(property_name, constraint_name, inner_type, element, index)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.validate_union_element(property_name, constraint_name, inner_type, element, index)
|
|
83
|
+
return if inner_type.any? { |t| element.is_a?(t) }
|
|
84
|
+
|
|
85
|
+
raise_array_constraint_error(
|
|
86
|
+
property_name: property_name,
|
|
87
|
+
constraint_name: constraint_name,
|
|
88
|
+
index: index,
|
|
89
|
+
expected: inner_type.join(' or '),
|
|
90
|
+
got: element
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.validate_single_type_element(property_name, constraint_name, inner_type, element, index)
|
|
95
|
+
# Skip if element is a boolean (booleans are valid in many contexts)
|
|
96
|
+
return if [true, false].include?(element)
|
|
97
|
+
|
|
98
|
+
if TypeIntrospection.boolean_type?(inner_type)
|
|
99
|
+
raise_array_constraint_error(
|
|
100
|
+
property_name: property_name,
|
|
101
|
+
constraint_name: constraint_name,
|
|
102
|
+
index: index,
|
|
103
|
+
expected: 'Boolean (true or false)',
|
|
104
|
+
got: element
|
|
105
|
+
)
|
|
106
|
+
elsif !element.is_a?(inner_type)
|
|
107
|
+
raise_array_constraint_error(
|
|
108
|
+
property_name: property_name,
|
|
109
|
+
constraint_name: constraint_name,
|
|
110
|
+
index: index,
|
|
111
|
+
expected: inner_type,
|
|
112
|
+
got: element
|
|
113
|
+
)
|
|
81
114
|
end
|
|
82
115
|
end
|
|
83
116
|
|
|
84
117
|
def self.validate_constraint_value(property_name:, constraint_name:, value_type:, value:)
|
|
85
118
|
return if value.nil?
|
|
86
119
|
|
|
87
|
-
if
|
|
120
|
+
if TypeIntrospection.boolean_type?(value_type)
|
|
88
121
|
return if value.is_a?(Array) && value.all? { |v| [true, false].include?(v) }
|
|
89
122
|
|
|
90
123
|
unless [true, false].include?(value)
|
|
@@ -109,8 +142,7 @@ module EasyTalk
|
|
|
109
142
|
)
|
|
110
143
|
end
|
|
111
144
|
# Handle array types specifically
|
|
112
|
-
elsif
|
|
113
|
-
(value_type.respond_to?(:to_s) && value_type.to_s.include?('T::Array'))
|
|
145
|
+
elsif TypeIntrospection.typed_array?(value_type)
|
|
114
146
|
# This is an array type, validate it
|
|
115
147
|
validate_typed_array_values(
|
|
116
148
|
property_name: property_name,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyTalk
|
|
4
|
+
# Implements JSON Schema equality semantics for comparing values.
|
|
5
|
+
#
|
|
6
|
+
# Per JSON Schema specification:
|
|
7
|
+
# - Objects with same keys/values in different order are equal
|
|
8
|
+
# - Numbers that are mathematically equal are equal (1 == 1.0)
|
|
9
|
+
# - Type matters for non-numbers (true != 1, false != 0)
|
|
10
|
+
module JsonSchemaEquality
|
|
11
|
+
# Maximum nesting depth to prevent SystemStackError on deeply nested structures
|
|
12
|
+
MAX_DEPTH = 100
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Check if an array contains duplicate values using JSON Schema equality.
|
|
16
|
+
# Uses a Set for O(n) performance and early termination on first duplicate.
|
|
17
|
+
def duplicates?(array)
|
|
18
|
+
seen = Set.new
|
|
19
|
+
array.any? { |item| !seen.add?(normalize(item)) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Normalize a value for JSON Schema equality comparison
|
|
23
|
+
# @param value [Object] The value to normalize
|
|
24
|
+
# @param depth [Integer] Current recursion depth (for stack overflow protection)
|
|
25
|
+
# @raise [ArgumentError] if nesting depth exceeds MAX_DEPTH
|
|
26
|
+
def normalize(value, depth = 0)
|
|
27
|
+
raise ArgumentError, "Nesting depth exceeds maximum of #{MAX_DEPTH}" if depth > MAX_DEPTH
|
|
28
|
+
|
|
29
|
+
case value
|
|
30
|
+
when Hash
|
|
31
|
+
# Convert keys to strings before sorting to handle mixed key types (Symbol/String)
|
|
32
|
+
# and ensure consistent, order-independent comparison (JSON only has string keys)
|
|
33
|
+
value.map { |k, v| [k.to_s, normalize(v, depth + 1)] }.sort
|
|
34
|
+
when Array
|
|
35
|
+
value.map { |item| normalize(item, depth + 1) }
|
|
36
|
+
when Integer, Float
|
|
37
|
+
# Normalize numbers to a canonical form for mathematical equality
|
|
38
|
+
value.to_r
|
|
39
|
+
else
|
|
40
|
+
# Booleans, strings, nil - preserve as-is (type matters)
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/easy_talk/keywords.rb
CHANGED
data/lib/easy_talk/model.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
require 'json'
|
|
4
5
|
require 'active_support'
|
|
@@ -10,6 +11,7 @@ require 'active_model'
|
|
|
10
11
|
require_relative 'builders/object_builder'
|
|
11
12
|
require_relative 'schema_definition'
|
|
12
13
|
require_relative 'validation_builder'
|
|
14
|
+
require_relative 'error_formatter'
|
|
13
15
|
|
|
14
16
|
module EasyTalk
|
|
15
17
|
# The `Model` module is a mixin that provides functionality for defining and accessing the schema of a model.
|
|
@@ -41,39 +43,70 @@ module EasyTalk
|
|
|
41
43
|
base.include ActiveModel::Validations
|
|
42
44
|
base.extend ActiveModel::Callbacks
|
|
43
45
|
base.include(InstanceMethods)
|
|
46
|
+
base.include(ErrorFormatter::InstanceMethods)
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
# Instance methods mixed into models that include EasyTalk::Model
|
|
47
50
|
module InstanceMethods
|
|
48
51
|
def initialize(attributes = {})
|
|
49
52
|
@additional_properties = {}
|
|
53
|
+
provided_keys = attributes.keys.to_set(&:to_sym)
|
|
54
|
+
|
|
50
55
|
super # Perform initial mass assignment
|
|
51
56
|
|
|
52
|
-
# After initial assignment, instantiate nested EasyTalk::Model objects
|
|
53
57
|
schema_def = self.class.schema_definition
|
|
54
|
-
|
|
55
|
-
# Only proceed if we have a valid schema definition
|
|
56
58
|
return unless schema_def.respond_to?(:schema) && schema_def.schema.is_a?(Hash)
|
|
57
59
|
|
|
58
60
|
(schema_def.schema[:properties] || {}).each do |prop_name, prop_definition|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
process_property_initialization(prop_name, prop_definition, provided_keys)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def process_property_initialization(prop_name, prop_definition, provided_keys)
|
|
68
|
+
defined_type = prop_definition[:type]
|
|
69
|
+
nilable_type = defined_type.respond_to?(:nilable?) && defined_type.nilable?
|
|
70
|
+
|
|
71
|
+
apply_default_value(prop_name, prop_definition, provided_keys)
|
|
72
|
+
|
|
73
|
+
current_value = public_send(prop_name)
|
|
74
|
+
return if nilable_type && current_value.nil?
|
|
63
75
|
|
|
64
|
-
|
|
76
|
+
defined_type = T::Utils::Nilable.get_underlying_type(defined_type) if nilable_type
|
|
77
|
+
instantiate_nested_models(prop_name, defined_type, current_value)
|
|
78
|
+
end
|
|
65
79
|
|
|
66
|
-
|
|
80
|
+
def apply_default_value(prop_name, prop_definition, provided_keys)
|
|
81
|
+
return if provided_keys.include?(prop_name)
|
|
67
82
|
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
default_value = prop_definition.dig(:constraints, :default)
|
|
84
|
+
public_send("#{prop_name}=", default_value) unless default_value.nil?
|
|
85
|
+
end
|
|
70
86
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
def instantiate_nested_models(prop_name, defined_type, current_value)
|
|
88
|
+
# Single nested model: convert Hash to model instance
|
|
89
|
+
if defined_type.is_a?(Class) && defined_type.include?(EasyTalk::Model) && current_value.is_a?(Hash)
|
|
90
|
+
public_send("#{prop_name}=", defined_type.new(current_value))
|
|
91
|
+
return
|
|
74
92
|
end
|
|
93
|
+
|
|
94
|
+
# Array of nested models: convert Hash items to model instances
|
|
95
|
+
instantiate_array_items(prop_name, defined_type, current_value)
|
|
75
96
|
end
|
|
76
97
|
|
|
98
|
+
def instantiate_array_items(prop_name, defined_type, current_value)
|
|
99
|
+
return unless defined_type.is_a?(T::Types::TypedArray) && current_value.is_a?(Array)
|
|
100
|
+
|
|
101
|
+
item_type = defined_type.type.respond_to?(:raw_type) ? defined_type.type.raw_type : nil
|
|
102
|
+
return unless item_type.is_a?(Class) && item_type.include?(EasyTalk::Model)
|
|
103
|
+
|
|
104
|
+
instantiated = current_value.map { |item| item.is_a?(Hash) ? item_type.new(item) : item }
|
|
105
|
+
public_send("#{prop_name}=", instantiated)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
public
|
|
109
|
+
|
|
77
110
|
def method_missing(method_name, *args)
|
|
78
111
|
method_string = method_name.to_s
|
|
79
112
|
if method_string.end_with?('=')
|
|
@@ -91,9 +124,10 @@ module EasyTalk
|
|
|
91
124
|
end
|
|
92
125
|
|
|
93
126
|
def respond_to_missing?(method_name, include_private = false)
|
|
127
|
+
return super unless self.class.additional_properties_allowed?
|
|
128
|
+
|
|
94
129
|
method_string = method_name.to_s
|
|
95
|
-
method_string.end_with?('=')
|
|
96
|
-
self.class.additional_properties_allowed? || super
|
|
130
|
+
method_string.end_with?('=') || @additional_properties.key?(method_string) || super
|
|
97
131
|
end
|
|
98
132
|
|
|
99
133
|
# Add to_hash method to convert defined properties to hash
|
|
@@ -136,6 +170,8 @@ module EasyTalk
|
|
|
136
170
|
|
|
137
171
|
# Module containing class-level methods for defining and accessing the schema of a model.
|
|
138
172
|
module ClassMethods
|
|
173
|
+
include SchemaMethods
|
|
174
|
+
|
|
139
175
|
# Returns the schema for the model.
|
|
140
176
|
#
|
|
141
177
|
# @return [Schema] The schema for the model.
|
|
@@ -147,103 +183,124 @@ module EasyTalk
|
|
|
147
183
|
end
|
|
148
184
|
end
|
|
149
185
|
|
|
150
|
-
#
|
|
186
|
+
# Define the schema for the model using the provided block.
|
|
151
187
|
#
|
|
152
|
-
# @
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
#
|
|
158
|
-
#
|
|
188
|
+
# @param options [Hash] Options for schema definition
|
|
189
|
+
# @option options [Boolean, Symbol, Class] :validations Controls validation behavior:
|
|
190
|
+
# - true: Enable validations using the configured adapter (default behavior)
|
|
191
|
+
# - false: Disable validations for this model
|
|
192
|
+
# - :none: Use the NoneAdapter (no validations)
|
|
193
|
+
# - :active_model: Use the ActiveModelAdapter
|
|
194
|
+
# - CustomAdapter: Use a custom adapter class
|
|
195
|
+
# @yield The block to define the schema.
|
|
196
|
+
# @raise [ArgumentError] If the class does not have a name.
|
|
159
197
|
#
|
|
160
|
-
# @
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
|
|
198
|
+
# @example Disable validations for a model
|
|
199
|
+
# define_schema(validations: false) do
|
|
200
|
+
# property :name, String
|
|
201
|
+
# end
|
|
202
|
+
#
|
|
203
|
+
# @example Use a custom adapter
|
|
204
|
+
# define_schema(validations: MyCustomAdapter) do
|
|
205
|
+
# property :name, String
|
|
206
|
+
# end
|
|
207
|
+
def define_schema(options = {}, &)
|
|
208
|
+
raise ArgumentError, 'The class must have a name' unless name.present?
|
|
166
209
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
schema_uri = resolve_schema_uri
|
|
171
|
-
id_uri = resolve_schema_id
|
|
210
|
+
@schema_definition = SchemaDefinition.new(name)
|
|
211
|
+
@schema_definition.klass = self # Pass the model class to the schema definition
|
|
212
|
+
@schema_definition.instance_eval(&)
|
|
172
213
|
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
prefix['$schema'] = schema_uri if schema_uri
|
|
176
|
-
prefix['$id'] = id_uri if id_uri
|
|
214
|
+
# Store validation options for this model
|
|
215
|
+
@validation_options = normalize_validation_options(options)
|
|
177
216
|
|
|
178
|
-
|
|
217
|
+
# Define accessors immediately based on schema_definition
|
|
218
|
+
defined_properties = (@schema_definition.schema[:properties] || {}).keys
|
|
219
|
+
attr_accessor(*defined_properties)
|
|
179
220
|
|
|
180
|
-
|
|
181
|
-
|
|
221
|
+
# Track which properties have had validations applied
|
|
222
|
+
@validated_properties ||= Set.new
|
|
182
223
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
model_version = @schema_definition&.schema&.dig(:schema_version)
|
|
224
|
+
# Initialize mutex eagerly for thread-safe schema-level validation application
|
|
225
|
+
@schema_level_validation_lock = Mutex.new
|
|
186
226
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return nil if model_version == :none
|
|
227
|
+
# Apply validations using the adapter system
|
|
228
|
+
apply_schema_validations
|
|
190
229
|
|
|
191
|
-
|
|
192
|
-
else
|
|
193
|
-
# Fall back to global configuration
|
|
194
|
-
EasyTalk.configuration.schema_uri
|
|
195
|
-
end
|
|
230
|
+
@schema_definition
|
|
196
231
|
end
|
|
197
232
|
|
|
198
|
-
|
|
199
|
-
def resolve_schema_id
|
|
200
|
-
model_id = @schema_definition&.schema&.dig(:schema_id)
|
|
201
|
-
|
|
202
|
-
if model_id
|
|
203
|
-
# Per-model override - :none means explicitly no $id
|
|
204
|
-
return nil if model_id == :none
|
|
233
|
+
private
|
|
205
234
|
|
|
206
|
-
|
|
235
|
+
# Normalize validation options from various input formats.
|
|
236
|
+
#
|
|
237
|
+
# @param options [Hash] The options hash from define_schema
|
|
238
|
+
# @return [Hash] Normalized options with :enabled and :adapter keys
|
|
239
|
+
def normalize_validation_options(options)
|
|
240
|
+
validations = options.fetch(:validations, nil)
|
|
241
|
+
|
|
242
|
+
case validations
|
|
243
|
+
when nil
|
|
244
|
+
# Use global configuration
|
|
245
|
+
{ enabled: EasyTalk.configuration.auto_validations,
|
|
246
|
+
adapter: EasyTalk.configuration.validation_adapter }
|
|
247
|
+
when false
|
|
248
|
+
# Explicitly disabled
|
|
249
|
+
{ enabled: false, adapter: :none }
|
|
250
|
+
when true
|
|
251
|
+
# Explicitly enabled with configured adapter
|
|
252
|
+
{ enabled: true, adapter: EasyTalk.configuration.validation_adapter }
|
|
253
|
+
when Symbol, Class
|
|
254
|
+
# Specific adapter specified
|
|
255
|
+
{ enabled: true, adapter: validations }
|
|
207
256
|
else
|
|
208
|
-
|
|
209
|
-
|
|
257
|
+
raise ArgumentError, "Invalid validations option: #{validations.inspect}. " \
|
|
258
|
+
"Expected true, false, Symbol, or Class."
|
|
210
259
|
end
|
|
211
260
|
end
|
|
212
261
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
# Define the schema for the model using the provided block.
|
|
262
|
+
# Apply validations to all schema properties using the configured adapter.
|
|
216
263
|
#
|
|
217
|
-
# @
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
raise ArgumentError, 'The class must have a name' unless name.present?
|
|
264
|
+
# @return [void]
|
|
265
|
+
def apply_schema_validations
|
|
266
|
+
return unless @validation_options[:enabled]
|
|
221
267
|
|
|
222
|
-
|
|
223
|
-
@schema_definition.klass = self # Pass the model class to the schema definition
|
|
224
|
-
@schema_definition.instance_eval(&)
|
|
268
|
+
adapter = ValidationAdapters::Registry.resolve(@validation_options[:adapter])
|
|
225
269
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
270
|
+
(@schema_definition.schema[:properties] || {}).each do |prop_name, prop_def|
|
|
271
|
+
# Skip if already validated
|
|
272
|
+
next if @validated_properties.include?(prop_name)
|
|
229
273
|
|
|
230
|
-
|
|
231
|
-
|
|
274
|
+
# Skip if property has validate: false
|
|
275
|
+
next if prop_def[:constraints][:validate] == false
|
|
232
276
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
(@schema_definition.schema[:properties] || {}).each do |prop_name, prop_def|
|
|
236
|
-
# Only apply validations if they haven't been applied yet
|
|
237
|
-
unless @validated_properties.include?(prop_name)
|
|
238
|
-
ValidationBuilder.build_validations(self, prop_name, prop_def[:type], prop_def[:constraints])
|
|
239
|
-
@validated_properties.add(prop_name)
|
|
240
|
-
end
|
|
241
|
-
end
|
|
277
|
+
adapter.build_validations(self, prop_name, prop_def[:type], prop_def[:constraints])
|
|
278
|
+
@validated_properties.add(prop_name)
|
|
242
279
|
end
|
|
243
280
|
|
|
244
|
-
|
|
281
|
+
# Apply schema-level validations (min_properties, max_properties, dependent_required)
|
|
282
|
+
apply_schema_level_validations(adapter)
|
|
245
283
|
end
|
|
246
284
|
|
|
285
|
+
# Apply schema-level validations for object-level constraints.
|
|
286
|
+
# Uses double-checked locking for thread safety.
|
|
287
|
+
# The mutex is initialized eagerly in define_schema.
|
|
288
|
+
#
|
|
289
|
+
# @param adapter [Class] The validation adapter class
|
|
290
|
+
# @return [void]
|
|
291
|
+
def apply_schema_level_validations(adapter)
|
|
292
|
+
return if @schema_level_validations_applied
|
|
293
|
+
|
|
294
|
+
@schema_level_validation_lock.synchronize do
|
|
295
|
+
return if @schema_level_validations_applied
|
|
296
|
+
|
|
297
|
+
adapter.build_schema_validations(self, @schema_definition.schema)
|
|
298
|
+
@schema_level_validations_applied = true
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
public
|
|
303
|
+
|
|
247
304
|
# Returns the unvalidated schema definition for the model.
|
|
248
305
|
#
|
|
249
306
|
# @return [SchemaDefinition] The unvalidated schema definition for the model.
|
|
@@ -252,7 +309,9 @@ module EasyTalk
|
|
|
252
309
|
end
|
|
253
310
|
|
|
254
311
|
def additional_properties_allowed?
|
|
255
|
-
@schema_definition&.schema&.fetch(:additional_properties, false)
|
|
312
|
+
ap = @schema_definition&.schema&.fetch(:additional_properties, false)
|
|
313
|
+
# Allow if true, or if it's a schema object (Class or Hash with type)
|
|
314
|
+
ap == true || ap.is_a?(Class) || ap.is_a?(Hash)
|
|
256
315
|
end
|
|
257
316
|
|
|
258
317
|
# Returns the property names defined in the schema
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
module EasyTalk
|
|
5
|
+
module ModelHelper
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { params(type: T.untyped).returns(T::Boolean) }
|
|
9
|
+
def self.easytalk_model?(type)
|
|
10
|
+
type.is_a?(Class) &&
|
|
11
|
+
type.respond_to?(:schema) &&
|
|
12
|
+
type.respond_to?(:ref_template) &&
|
|
13
|
+
defined?(EasyTalk::Model) &&
|
|
14
|
+
type.include?(EasyTalk::Model)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
module EasyTalk
|
|
5
|
+
module NamingStrategies
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
IDENTITY = lambda(&:to_sym)
|
|
9
|
+
SNAKE_CASE = ->(property_name) { property_name.to_s.underscore.to_sym }
|
|
10
|
+
CAMEL_CASE = ->(property_name) { property_name.to_s.tr('-', '_').camelize(:lower).to_sym }
|
|
11
|
+
PASCAL_CASE = ->(property_name) { property_name.to_s.tr('-', '_').camelize.to_sym }
|
|
12
|
+
|
|
13
|
+
sig { params(strategy: T.any(Symbol, T.proc.params(arg0: T.untyped).returns(Symbol))).returns(T.proc.params(arg0: T.untyped).returns(Symbol)) }
|
|
14
|
+
def self.derive_strategy(strategy)
|
|
15
|
+
if strategy.is_a?(Symbol)
|
|
16
|
+
"EasyTalk::NamingStrategies::#{strategy.to_s.upcase}".constantize
|
|
17
|
+
elsif strategy.is_a?(Proc)
|
|
18
|
+
strategy
|
|
19
|
+
else
|
|
20
|
+
raise ArgumentError, 'Invalid property naming strategy. Must be a Symbol or a Proc.'
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|