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