easy_talk 1.0.4 → 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.
@@ -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.4'
4
+ VERSION = '2.0.0'
5
5
  end
metadata CHANGED
@@ -1,196 +1,56 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easy_talk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.4
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-12 00:00:00.000000000 Z
10
+ date: 2025-06-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activemodel
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
18
  version: '7.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activesupport
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - ">="
30
+ - - "~>"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '7.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - ">="
37
+ - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '7.0'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: sorbet-runtime
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - ">="
44
+ - - "~>"
45
45
  - !ruby/object:Gem::Version
46
46
  version: '0.5'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
- - - ">="
51
+ - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.5'
54
- - !ruby/object:Gem::Dependency
55
- name: activerecord
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - ">="
59
- - !ruby/object:Gem::Version
60
- version: '7.0'
61
- type: :development
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - ">="
66
- - !ruby/object:Gem::Version
67
- version: '7.0'
68
- - !ruby/object:Gem::Dependency
69
- name: pry-byebug
70
- requirement: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - ">="
73
- - !ruby/object:Gem::Version
74
- version: '3.10'
75
- type: :development
76
- prerelease: false
77
- version_requirements: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - ">="
80
- - !ruby/object:Gem::Version
81
- version: '3.10'
82
- - !ruby/object:Gem::Dependency
83
- name: rake
84
- requirement: !ruby/object:Gem::Requirement
85
- requirements:
86
- - - ">="
87
- - !ruby/object:Gem::Version
88
- version: '13.1'
89
- type: :development
90
- prerelease: false
91
- version_requirements: !ruby/object:Gem::Requirement
92
- requirements:
93
- - - ">="
94
- - !ruby/object:Gem::Version
95
- version: '13.1'
96
- - !ruby/object:Gem::Dependency
97
- name: rspec
98
- requirement: !ruby/object:Gem::Requirement
99
- requirements:
100
- - - ">="
101
- - !ruby/object:Gem::Version
102
- version: '3.0'
103
- type: :development
104
- prerelease: false
105
- version_requirements: !ruby/object:Gem::Requirement
106
- requirements:
107
- - - ">="
108
- - !ruby/object:Gem::Version
109
- version: '3.0'
110
- - !ruby/object:Gem::Dependency
111
- name: rspec-json_expectations
112
- requirement: !ruby/object:Gem::Requirement
113
- requirements:
114
- - - ">="
115
- - !ruby/object:Gem::Version
116
- version: '2.0'
117
- type: :development
118
- prerelease: false
119
- version_requirements: !ruby/object:Gem::Requirement
120
- requirements:
121
- - - ">="
122
- - !ruby/object:Gem::Version
123
- version: '2.0'
124
- - !ruby/object:Gem::Dependency
125
- name: rspec-mocks
126
- requirement: !ruby/object:Gem::Requirement
127
- requirements:
128
- - - ">="
129
- - !ruby/object:Gem::Version
130
- version: '3.13'
131
- type: :development
132
- prerelease: false
133
- version_requirements: !ruby/object:Gem::Requirement
134
- requirements:
135
- - - ">="
136
- - !ruby/object:Gem::Version
137
- version: '3.13'
138
- - !ruby/object:Gem::Dependency
139
- name: rubocop
140
- requirement: !ruby/object:Gem::Requirement
141
- requirements:
142
- - - ">="
143
- - !ruby/object:Gem::Version
144
- version: '1.21'
145
- type: :development
146
- prerelease: false
147
- version_requirements: !ruby/object:Gem::Requirement
148
- requirements:
149
- - - ">="
150
- - !ruby/object:Gem::Version
151
- version: '1.21'
152
- - !ruby/object:Gem::Dependency
153
- name: rubocop-rake
154
- requirement: !ruby/object:Gem::Requirement
155
- requirements:
156
- - - ">="
157
- - !ruby/object:Gem::Version
158
- version: '0.6'
159
- type: :development
160
- prerelease: false
161
- version_requirements: !ruby/object:Gem::Requirement
162
- requirements:
163
- - - ">="
164
- - !ruby/object:Gem::Version
165
- version: '0.6'
166
- - !ruby/object:Gem::Dependency
167
- name: rubocop-rspec
168
- requirement: !ruby/object:Gem::Requirement
169
- requirements:
170
- - - ">="
171
- - !ruby/object:Gem::Version
172
- version: '2.29'
173
- type: :development
174
- prerelease: false
175
- version_requirements: !ruby/object:Gem::Requirement
176
- requirements:
177
- - - ">="
178
- - !ruby/object:Gem::Version
179
- version: '2.29'
180
- - !ruby/object:Gem::Dependency
181
- name: sqlite3
182
- requirement: !ruby/object:Gem::Requirement
183
- requirements:
184
- - - ">="
185
- - !ruby/object:Gem::Version
186
- version: '2'
187
- type: :development
188
- prerelease: false
189
- version_requirements: !ruby/object:Gem::Requirement
190
- requirements:
191
- - - ">="
192
- - !ruby/object:Gem::Version
193
- version: '2'
194
54
  description: Generate json-schema from plain Ruby classes.
195
55
  email:
196
56
  - bayona.sergio@gmail.com
@@ -211,6 +71,7 @@ files:
211
71
  - docs/_posts/2024-05-07-welcome-to-jekyll.markdown
212
72
  - docs/about.markdown
213
73
  - docs/index.markdown
74
+ - easy_talk.gemspec
214
75
  - lib/easy_talk.rb
215
76
  - lib/easy_talk/active_record_schema_builder.rb
216
77
  - lib/easy_talk/builders/base_builder.rb
@@ -236,6 +97,7 @@ files:
236
97
  - lib/easy_talk/tools/function_builder.rb
237
98
  - lib/easy_talk/types/base_composer.rb
238
99
  - lib/easy_talk/types/composer.rb
100
+ - lib/easy_talk/validation_builder.rb
239
101
  - lib/easy_talk/version.rb
240
102
  homepage: https://github.com/sergiobayona/easy_talk
241
103
  licenses:
@@ -243,8 +105,8 @@ licenses:
243
105
  metadata:
244
106
  allowed_push_host: https://rubygems.org
245
107
  homepage_uri: https://github.com/sergiobayona/easy_talk
246
- source_code_uri: https://github.com/sergiobayona/easy_talk
247
108
  changelog_uri: https://github.com/sergiobayona/easy_talk/blob/main/CHANGELOG.md
109
+ rubygems_mfa_required: 'true'
248
110
  rdoc_options: []
249
111
  require_paths:
250
112
  - lib