easy_talk 1.0.4 → 3.0.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'keywords'
4
4
  require_relative 'types/composer'
5
+ require_relative 'validation_builder'
5
6
 
6
7
  module EasyTalk
7
8
  #
@@ -16,11 +17,13 @@ module EasyTalk
16
17
  extend T::AllOf
17
18
 
18
19
  attr_reader :name, :schema
20
+ attr_accessor :klass # Add accessor for the model class
19
21
 
20
22
  def initialize(name, schema = {})
21
23
  @schema = schema
22
24
  @schema[:additional_properties] = false unless schema.key?(:additional_properties)
23
25
  @name = name
26
+ @klass = nil # Initialize klass to nil
24
27
  end
25
28
 
26
29
  EasyTalk::KEYWORDS.each do |keyword|
@@ -34,32 +37,24 @@ module EasyTalk
34
37
  @schema[:subschemas] += subschemas
35
38
  end
36
39
 
37
- sig do
38
- params(name: T.any(Symbol, String), type: T.untyped, constraints: T.untyped, blk: T.nilable(T.proc.void)).void
39
- end
40
- def property(name, type, constraints = {}, &blk)
40
+ def property(name, type, constraints = {}, &)
41
41
  validate_property_name(name)
42
42
  @schema[:properties] ||= {}
43
43
 
44
44
  if block_given?
45
- property_schema = SchemaDefinition.new(name)
46
- property_schema.instance_eval(&blk)
47
-
48
- @schema[:properties][name] = {
49
- type:,
50
- constraints:,
51
- properties: property_schema
52
- }
53
- else
54
- @schema[:properties][name] = { type:, constraints: }
45
+ raise ArgumentError,
46
+ 'Block-style sub-schemas are no longer supported. Use class references as types instead.'
55
47
  end
48
+
49
+ @schema[:properties][name] = { type:, constraints: }
56
50
  end
57
51
 
58
52
  def validate_property_name(name)
59
53
  return if name.to_s.match?(/^[A-Za-z_][A-Za-z0-9_]*$/)
60
54
 
61
- raise InvalidPropertyNameError,
62
- "Invalid property name '#{name}'. Must start with letter/underscore and contain only letters, numbers, underscores"
55
+ message = "Invalid property name '#{name}'. Must start with letter/underscore " \
56
+ 'and contain only letters, numbers, underscores'
57
+ raise InvalidPropertyNameError, message
63
58
  end
64
59
 
65
60
  def optional?
@@ -67,7 +62,7 @@ module EasyTalk
67
62
  end
68
63
 
69
64
  # Helper method for nullable and optional properties
70
- def nullable_optional_property(name, type, constraints = {}, &blk)
65
+ def nullable_optional_property(name, type, constraints = {})
71
66
  # Ensure type is nilable
72
67
  nilable_type = if type.respond_to?(:nilable?) && type.nilable?
73
68
  type
@@ -79,7 +74,7 @@ module EasyTalk
79
74
  constraints = constraints.merge(optional: true)
80
75
 
81
76
  # Call standard property method
82
- property(name, nilable_type, constraints, &blk)
77
+ property(name, nilable_type, constraints)
83
78
  end
84
79
  end
85
80
  end
@@ -54,13 +54,14 @@ end
54
54
 
55
55
  # Shorthand module for accessing the AllOf composer
56
56
  module T
57
+ # Provides composition logic for combining multiple schemas with AllOf semantics
57
58
  module AllOf
58
59
  # Creates a new instance of `EasyTalk::Types::Composer::AllOf` with the given arguments.
59
60
  #
60
61
  # @param args [Array] the list of arguments to be passed to the constructor
61
62
  # @return [EasyTalk::Types::Composer::AllOf] a new instance
62
- def self.[](*args)
63
- EasyTalk::Types::Composer::AllOf.new(*args)
63
+ def self.[](*)
64
+ EasyTalk::Types::Composer::AllOf.new(*)
64
65
  end
65
66
  end
66
67
 
@@ -70,8 +71,8 @@ module T
70
71
  #
71
72
  # @param args [Array] the list of arguments to be passed to the constructor
72
73
  # @return [EasyTalk::Types::Composer::AnyOf] a new instance
73
- def self.[](*args)
74
- EasyTalk::Types::Composer::AnyOf.new(*args)
74
+ def self.[](*)
75
+ EasyTalk::Types::Composer::AnyOf.new(*)
75
76
  end
76
77
  end
77
78
 
@@ -81,8 +82,8 @@ module T
81
82
  #
82
83
  # @param args [Array] the list of arguments to be passed to the constructor
83
84
  # @return [EasyTalk::Types::Composer::OneOf] a new instance
84
- def self.[](*args)
85
- EasyTalk::Types::Composer::OneOf.new(*args)
85
+ def self.[](*)
86
+ EasyTalk::Types::Composer::OneOf.new(*)
86
87
  end
87
88
  end
88
89
  end
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module EasyTalk
6
+ # The ValidationBuilder creates ActiveModel validations based on JSON Schema constraints
7
+ class ValidationBuilder
8
+ # Build validations for a property and apply them to the model class
9
+ #
10
+ # @param klass [Class] The model class to apply validations to
11
+ # @param property_name [Symbol, String] The name of the property
12
+ # @param type [Class, Object] The type of the property
13
+ # @param constraints [Hash] The JSON Schema constraints for the property
14
+ # @return [void]
15
+ def self.build_validations(klass, property_name, type, constraints)
16
+ builder = new(klass, property_name, type, constraints)
17
+ builder.apply_validations
18
+ end
19
+
20
+ # Initialize a new ValidationBuilder
21
+ #
22
+ # @param klass [Class] The model class to apply validations to
23
+ # @param property_name [Symbol, String] The name of the property
24
+ # @param type [Class, Object] The type of the property
25
+ # @param constraints [Hash] The JSON Schema constraints for the property
26
+ attr_reader :klass, :property_name, :type, :constraints
27
+
28
+ def initialize(klass, property_name, type, constraints)
29
+ @klass = klass
30
+ @property_name = property_name.to_sym
31
+ @type = type
32
+ @constraints = constraints || {}
33
+ end
34
+
35
+ # Apply validations based on property type and constraints
36
+ def apply_validations
37
+ # Determine if the type is boolean
38
+ type_class = get_type_class(@type)
39
+ is_boolean = type_class == [TrueClass, FalseClass] ||
40
+ type_class == TrueClass ||
41
+ type_class == FalseClass ||
42
+ @type.to_s.include?('T::Boolean')
43
+
44
+ # Skip presence validation for booleans and nilable types
45
+ apply_presence_validation unless optional? || is_boolean || nilable_type?
46
+ if nilable_type?
47
+ # For nilable types, get the inner type and apply validations to it
48
+ inner_type = extract_inner_type(@type)
49
+ apply_type_validations(inner_type)
50
+ else
51
+ apply_type_validations(@type)
52
+ end
53
+
54
+ # Common validations for most types
55
+ apply_enum_validation if @constraints[:enum]
56
+ apply_const_validation if @constraints[:const]
57
+ end
58
+
59
+ private
60
+
61
+ # Determine if a property is optional based on constraints and configuration
62
+ def optional?
63
+ @constraints[:optional] == true ||
64
+ (@type.respond_to?(:nilable?) && @type.nilable? && EasyTalk.configuration.nilable_is_optional)
65
+ end
66
+
67
+ # Check if the type is nilable (e.g., T.nilable(String))
68
+ def nilable_type?(type = @type)
69
+ type.respond_to?(:nilable?) && type.nilable?
70
+ end
71
+
72
+ # Extract the inner type from a complex type like T.nilable(String)
73
+ def extract_inner_type(type)
74
+ if type.respond_to?(:unwrap_nilable) && type.unwrap_nilable.respond_to?(:raw_type)
75
+ type.unwrap_nilable.raw_type
76
+ elsif type.respond_to?(:types)
77
+ # For union types like T.nilable(String), extract the non-nil type
78
+ type.types.find { |t| t.respond_to?(:raw_type) && t.raw_type != NilClass }
79
+ else
80
+ type
81
+ end
82
+ end
83
+
84
+ # Apply validations based on the type of the property
85
+ def apply_type_validations(type)
86
+ type_class = get_type_class(type)
87
+
88
+ if type_class == String
89
+ apply_string_validations
90
+ elsif type_class == Integer
91
+ apply_integer_validations
92
+ elsif [Float, BigDecimal].include?(type_class)
93
+ apply_number_validations
94
+ elsif type_class == Array
95
+ apply_array_validations(type)
96
+ elsif type_class == [TrueClass,
97
+ FalseClass] || [TrueClass,
98
+ FalseClass].include?(type_class) || type.to_s.include?('T::Boolean')
99
+ apply_boolean_validations
100
+ elsif type_class.is_a?(Object) && type_class.include?(EasyTalk::Model)
101
+ apply_object_validations
102
+ end
103
+ end
104
+
105
+ # Determine the actual class for a type, handling Sorbet types
106
+ def get_type_class(type)
107
+ if type.is_a?(Class)
108
+ type
109
+ elsif type.respond_to?(:raw_type)
110
+ type.raw_type
111
+ elsif type.is_a?(T::Types::TypedArray)
112
+ Array
113
+ elsif type.is_a?(Symbol) || type.is_a?(String)
114
+ begin
115
+ type.to_s.classify.constantize
116
+ rescue StandardError
117
+ String
118
+ end
119
+ elsif type.to_s.include?('T::Boolean')
120
+ [TrueClass, FalseClass] # Return both boolean classes
121
+ elsif nilable_type?(type)
122
+ extract_inner_type(type)
123
+ else
124
+ String # Default fallback
125
+ end
126
+ end
127
+
128
+ # Add presence validation for the property
129
+ def apply_presence_validation
130
+ @klass.validates @property_name, presence: true
131
+ end
132
+
133
+ # Validate string-specific constraints
134
+ def apply_string_validations
135
+ # Handle format constraints
136
+ apply_format_validation(@constraints[:format]) if @constraints[:format]
137
+
138
+ # Handle pattern (regex) constraints
139
+ @klass.validates @property_name, format: { with: Regexp.new(@constraints[:pattern]) } if @constraints[:pattern]
140
+
141
+ # Handle length constraints
142
+ begin
143
+ length_options = {}
144
+ length_options[:minimum] = @constraints[:min_length] if @constraints[:min_length].is_a?(Numeric) && @constraints[:min_length] >= 0
145
+ length_options[:maximum] = @constraints[:max_length] if @constraints[:max_length].is_a?(Numeric) && @constraints[:max_length] >= 0
146
+ @klass.validates @property_name, length: length_options if length_options.any?
147
+ rescue ArgumentError
148
+ # Silently ignore invalid length constraints
149
+ end
150
+ end
151
+
152
+ # Apply format-specific validations (email, url, etc.)
153
+ def apply_format_validation(format)
154
+ format_configs = {
155
+ 'email' => { with: URI::MailTo::EMAIL_REGEXP, message: 'must be a valid email address' },
156
+ 'uri' => { with: URI::DEFAULT_PARSER.make_regexp, message: 'must be a valid URL' },
157
+ 'url' => { with: URI::DEFAULT_PARSER.make_regexp, message: 'must be a valid URL' },
158
+ 'uuid' => { with: /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i, message: 'must be a valid UUID' },
159
+ 'date' => { with: /\A\d{4}-\d{2}-\d{2}\z/, message: 'must be a valid date in YYYY-MM-DD format' },
160
+ 'date-time' => { with: /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/, message: 'must be a valid ISO 8601 date-time' },
161
+ 'time' => { with: /\A\d{2}:\d{2}:\d{2}(?:\.\d+)?\z/, message: 'must be a valid time in HH:MM:SS format' }
162
+ }
163
+
164
+ config = format_configs[format.to_s]
165
+ @klass.validates @property_name, format: config if config
166
+ end
167
+
168
+ # Validate integer-specific constraints
169
+ def apply_integer_validations
170
+ apply_numeric_validations(only_integer: true)
171
+ end
172
+
173
+ # Validate number-specific constraints
174
+ def apply_number_validations
175
+ apply_numeric_validations(only_integer: false)
176
+ end
177
+
178
+ # Apply numeric validations for integers and floats
179
+ def apply_numeric_validations(only_integer: false)
180
+ begin
181
+ options = { only_integer: only_integer }
182
+
183
+ # Add range constraints - only if they are numeric
184
+ options[:greater_than_or_equal_to] = @constraints[:minimum] if @constraints[:minimum].is_a?(Numeric)
185
+ options[:less_than_or_equal_to] = @constraints[:maximum] if @constraints[:maximum].is_a?(Numeric)
186
+ options[:greater_than] = @constraints[:exclusive_minimum] if @constraints[:exclusive_minimum].is_a?(Numeric)
187
+ options[:less_than] = @constraints[:exclusive_maximum] if @constraints[:exclusive_maximum].is_a?(Numeric)
188
+
189
+ @klass.validates @property_name, numericality: options
190
+ rescue ArgumentError
191
+ # Silently ignore invalid numeric constraints
192
+ end
193
+
194
+ # Add multiple_of validation
195
+ return unless @constraints[:multiple_of]
196
+
197
+ prop_name = @property_name
198
+ multiple_of_value = @constraints[:multiple_of]
199
+ @klass.validate do |record|
200
+ value = record.public_send(prop_name)
201
+ record.errors.add(prop_name, "must be a multiple of #{multiple_of_value}") if value && (value % multiple_of_value != 0)
202
+ end
203
+ end
204
+
205
+ # Validate array-specific constraints
206
+ def apply_array_validations(type)
207
+ # Validate array length
208
+ if @constraints[:min_items] || @constraints[:max_items]
209
+ length_options = {}
210
+ length_options[:minimum] = @constraints[:min_items] if @constraints[:min_items]
211
+ length_options[:maximum] = @constraints[:max_items] if @constraints[:max_items]
212
+
213
+ @klass.validates @property_name, length: length_options
214
+ end
215
+
216
+ # Validate uniqueness within the array
217
+ if @constraints[:unique_items]
218
+ prop_name = @property_name
219
+ @klass.validate do |record|
220
+ value = record.public_send(prop_name)
221
+ record.errors.add(prop_name, 'must contain unique items') if value && value.uniq.length != value.length
222
+ end
223
+ end
224
+
225
+ # Validate array item types if using T::Array[SomeType]
226
+ return unless type.respond_to?(:type_parameter)
227
+
228
+ inner_type = type.type_parameter
229
+ prop_name = @property_name
230
+ @klass.validate do |record|
231
+ value = record.public_send(prop_name)
232
+ if value.is_a?(Array)
233
+ value.each_with_index do |item, index|
234
+ record.errors.add(prop_name, "item at index #{index} must be a #{inner_type}") unless item.is_a?(inner_type)
235
+ end
236
+ end
237
+ end
238
+ end
239
+
240
+ # Validate boolean-specific constraints
241
+ def apply_boolean_validations
242
+ # For boolean values, validate inclusion in [true, false]
243
+ # If not optional, don't allow nil (equivalent to presence validation for booleans)
244
+ if optional?
245
+ @klass.validates @property_name, inclusion: { in: [true, false] }, allow_nil: true
246
+ else
247
+ @klass.validates @property_name, inclusion: { in: [true, false] }
248
+ # Add custom validation for nil values that provides the "can't be blank" message
249
+ prop_name = @property_name
250
+ @klass.validate do |record|
251
+ value = record.public_send(prop_name)
252
+ record.errors.add(prop_name, "can't be blank") if value.nil?
253
+ end
254
+ end
255
+
256
+ # Add type validation to ensure the value is actually a boolean
257
+ prop_name = @property_name
258
+ @klass.validate do |record|
259
+ value = record.public_send(prop_name)
260
+ record.errors.add(prop_name, 'must be a boolean') if value && ![true, false].include?(value)
261
+ end
262
+ end
263
+
264
+ # Validate object/hash-specific constraints
265
+ def apply_object_validations
266
+ # Capture necessary variables outside the validation block's scope
267
+ prop_name = @property_name
268
+ expected_type = get_type_class(@type) # Get the raw model class
269
+
270
+ @klass.validate do |record|
271
+ nested_object = record.public_send(prop_name)
272
+
273
+ # Only validate if the nested object is present
274
+ if nested_object
275
+ # Check if the object is of the expected type (e.g., an actual Email instance)
276
+ if nested_object.is_a?(expected_type)
277
+ # Check if this object appears to be empty (created from an empty hash)
278
+ # by checking if all defined properties are nil/blank
279
+ properties = expected_type.schema_definition.schema[:properties] || {}
280
+ all_properties_blank = properties.keys.all? do |property|
281
+ value = nested_object.public_send(property)
282
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
283
+ end
284
+
285
+ if all_properties_blank
286
+ # Treat as blank and add a presence error to the parent field
287
+ record.errors.add(prop_name, "can't be blank")
288
+ else
289
+ # If it's the correct type and not empty, validate it
290
+ unless nested_object.valid?
291
+ # Merge errors from the nested object into the parent
292
+ nested_object.errors.each do |error|
293
+ # Prefix the attribute name (e.g., 'email.address')
294
+ nested_key = "#{prop_name}.#{error.attribute}"
295
+ record.errors.add(nested_key.to_sym, error.message)
296
+ end
297
+ end
298
+ end
299
+ else
300
+ # If present but not the correct type, add a type error
301
+ record.errors.add(prop_name, "must be a valid #{expected_type.name}")
302
+ end
303
+ end
304
+ # NOTE: Presence validation (if nested_object is nil) is handled
305
+ # by apply_presence_validation based on the property definition.
306
+ end
307
+ end
308
+
309
+ # Apply enum validation for inclusion in a specific list
310
+ def apply_enum_validation
311
+ @klass.validates @property_name, inclusion: {
312
+ in: @constraints[:enum],
313
+ message: "must be one of: #{@constraints[:enum].join(', ')}"
314
+ }
315
+ end
316
+
317
+ # Apply const validation for equality with a specific value
318
+ def apply_const_validation
319
+ const_value = @constraints[:const]
320
+ prop_name = @property_name
321
+ @klass.validate do |record|
322
+ value = record.public_send(prop_name)
323
+ record.errors.add(prop_name, "must be equal to #{const_value}") if !value.nil? && value != const_value
324
+ end
325
+ end
326
+ end
327
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyTalk
4
- VERSION = '1.0.4'
4
+ VERSION = '3.0.0'
5
5
  end