easy_talk 2.0.0 → 3.1.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.
data/easy_talk.gemspec CHANGED
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ['Sergio Bayona']
9
9
  spec.email = ['bayona.sergio@gmail.com']
10
10
 
11
- spec.summary = 'Generate json-schema from Ruby classes.'
12
- spec.description = 'Generate json-schema from plain Ruby classes.'
11
+ spec.summary = 'Generate json-schema from Ruby classes with ActiveModel integration.'
12
+ spec.description = 'Generate json-schema from plain Ruby classes with ActiveModel integration for validations and serialization.'
13
13
  spec.homepage = 'https://github.com/sergiobayona/easy_talk'
14
14
  spec.license = 'MIT'
15
15
  spec.required_ruby_version = '>= 3.2'
@@ -30,8 +30,9 @@ Gem::Specification.new do |spec|
30
30
 
31
31
  spec.require_paths = ['lib']
32
32
 
33
- spec.add_dependency 'activemodel', '~> 7.0'
34
- spec.add_dependency 'activesupport', '~> 7.0'
33
+ spec.add_dependency 'activemodel', ['>= 7.0', '< 9.0']
34
+ spec.add_dependency 'activesupport', ['>= 7.0', '< 9.0']
35
+ spec.add_dependency 'js_regex', '~> 3.0'
35
36
  spec.add_dependency 'sorbet-runtime', '~> 0.5'
36
37
 
37
38
  spec.metadata['rubygems_mfa_required'] = 'true'
@@ -7,6 +7,7 @@ module EasyTalk
7
7
  # Builder class for integer properties.
8
8
  class IntegerBuilder < BaseBuilder
9
9
  extend T::Sig
10
+
10
11
  VALID_OPTIONS = {
11
12
  minimum: { type: Integer, key: :minimum },
12
13
  maximum: { type: Integer, key: :maximum },
@@ -39,6 +39,9 @@ module EasyTalk
39
39
  # We'll collect required property names in this Set
40
40
  @required_properties = Set.new
41
41
 
42
+ # Collect models that are referenced via $ref for $defs generation
43
+ @ref_models = Set.new
44
+
42
45
  # Usually the name is a string (class name). Fallback to :klass if nil.
43
46
  name_for_builder = schema_definition.name ? schema_definition.name.to_sym : :klass
44
47
 
@@ -60,12 +63,20 @@ module EasyTalk
60
63
  # Start with a copy of the raw schema
61
64
  merged = @original_schema.dup
62
65
 
66
+ # Remove schema_version and schema_id as they're handled separately in json_schema output
67
+ merged.delete(:schema_version)
68
+ merged.delete(:schema_id)
69
+
63
70
  # Extract and build sub-schemas first (handles allOf/anyOf/oneOf references, etc.)
64
71
  process_subschemas(merged)
65
72
 
66
73
  # Build :properties into a final form (and find "required" props)
74
+ # This also collects models that use $ref into @ref_models
67
75
  merged[:properties] = build_properties(merged.delete(:properties))
68
76
 
77
+ # Add $defs for any models that are referenced via $ref
78
+ add_ref_model_defs(merged) if @ref_models.any?
79
+
69
80
  # Populate the final "required" array from @required_properties
70
81
  merged[:required] = @required_properties.to_a if @required_properties.any?
71
82
 
@@ -127,6 +138,7 @@ module EasyTalk
127
138
  ##
128
139
  # Builds a single property. Could be a nested schema if it has sub-properties,
129
140
  # or a standard scalar property (String, Integer, etc.).
141
+ # Also tracks EasyTalk models that should be added to $defs when using $ref.
130
142
  #
131
143
  def build_property(prop_name, prop_options)
132
144
  @property_cache ||= {}
@@ -135,9 +147,91 @@ module EasyTalk
135
147
  @property_cache[prop_name] ||= begin
136
148
  # Remove optional constraints from the property
137
149
  constraints = prop_options[:constraints].except(:optional)
150
+ prop_type = prop_options[:type]
151
+
152
+ # Track models that will use $ref for later $defs generation
153
+ collect_ref_models(prop_type, constraints)
154
+
138
155
  # Normal property: e.g. { type: String, constraints: {...} }
139
- Property.new(prop_name, prop_options[:type], constraints)
156
+ Property.new(prop_name, prop_type, constraints)
157
+ end
158
+ end
159
+
160
+ ##
161
+ # Collects EasyTalk models that will be referenced via $ref.
162
+ # These models need to be added to $defs in the final schema.
163
+ #
164
+ def collect_ref_models(prop_type, constraints)
165
+ # Check if this type should use $ref
166
+ if should_collect_ref?(prop_type, constraints)
167
+ @ref_models.add(prop_type)
168
+ # Handle typed arrays with EasyTalk model items
169
+ elsif typed_array_with_model?(prop_type)
170
+ inner_type = prop_type.type.raw_type
171
+ @ref_models.add(inner_type) if should_collect_ref?(inner_type, constraints)
172
+ # Handle nilable types
173
+ elsif nilable_with_model?(prop_type)
174
+ actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
175
+ @ref_models.add(actual_type) if should_collect_ref?(actual_type, constraints)
176
+ end
177
+ end
178
+
179
+ ##
180
+ # Determines if a type should be collected for $ref based on config and constraints.
181
+ #
182
+ def should_collect_ref?(check_type, constraints)
183
+ return false unless easytalk_model?(check_type)
184
+
185
+ # Per-property constraint takes precedence
186
+ return constraints[:ref] if constraints.key?(:ref)
187
+
188
+ # Fall back to global configuration
189
+ EasyTalk.configuration.use_refs
190
+ end
191
+
192
+ ##
193
+ # Checks if a type is an EasyTalk model.
194
+ #
195
+ def easytalk_model?(check_type)
196
+ check_type.is_a?(Class) &&
197
+ check_type.respond_to?(:schema) &&
198
+ check_type.respond_to?(:ref_template) &&
199
+ defined?(EasyTalk::Model) &&
200
+ check_type.include?(EasyTalk::Model)
201
+ end
202
+
203
+ ##
204
+ # Checks if type is a typed array containing an EasyTalk model.
205
+ #
206
+ def typed_array_with_model?(prop_type)
207
+ return false unless prop_type.is_a?(T::Types::TypedArray)
208
+
209
+ inner_type = prop_type.type.raw_type
210
+ easytalk_model?(inner_type)
211
+ end
212
+
213
+ ##
214
+ # Checks if type is nilable and contains an EasyTalk model.
215
+ #
216
+ def nilable_with_model?(prop_type)
217
+ return false unless prop_type.respond_to?(:types)
218
+ return false unless prop_type.types.all? { |t| t.respond_to?(:raw_type) }
219
+ return false unless prop_type.types.any? { |t| t.raw_type == NilClass }
220
+
221
+ actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
222
+ easytalk_model?(actual_type)
223
+ end
224
+
225
+ ##
226
+ # Adds $defs entries for all collected ref models.
227
+ #
228
+ def add_ref_model_defs(schema_hash)
229
+ definitions = @ref_models.each_with_object({}) do |model, acc|
230
+ acc[model.name] = model.schema
140
231
  end
232
+
233
+ existing_defs = schema_hash[:defs] || {}
234
+ schema_hash[:defs] = existing_defs.merge(definitions)
141
235
  end
142
236
 
143
237
  ##
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'base_builder'
4
+ require 'js_regex' # Compile the ruby regex to JS regex
4
5
  require 'sorbet-runtime' # Add the import statement for the T module
5
6
 
6
7
  module EasyTalk
@@ -8,6 +9,7 @@ module EasyTalk
8
9
  # Builder class for string properties.
9
10
  class StringBuilder < BaseBuilder
10
11
  extend T::Sig
12
+
11
13
  VALID_OPTIONS = {
12
14
  format: { type: String, key: :format },
13
15
  pattern: { type: String, key: :pattern },
@@ -22,6 +24,13 @@ module EasyTalk
22
24
  def initialize(name, constraints = {})
23
25
  super(name, { type: 'string' }, constraints, VALID_OPTIONS)
24
26
  end
27
+
28
+ def build
29
+ super.tap do |schema|
30
+ pattern = schema[:pattern]
31
+ schema[:pattern] = JsRegex.new(pattern).source if pattern.is_a?(String)
32
+ end
33
+ end
25
34
  end
26
35
  end
27
36
  end
@@ -14,7 +14,8 @@ module EasyTalk
14
14
  max_items: { type: Integer, key: :maxItems },
15
15
  unique_items: { type: T::Boolean, key: :uniqueItems },
16
16
  enum: { type: T::Array[T.untyped], key: :enum },
17
- const: { type: T::Array[T.untyped], key: :const }
17
+ const: { type: T::Array[T.untyped], key: :const },
18
+ ref: { type: T::Boolean, key: :ref }
18
19
  }.freeze
19
20
 
20
21
  attr_reader :type
@@ -35,7 +36,9 @@ module EasyTalk
35
36
  sig { returns(T::Hash[Symbol, T.untyped]) }
36
37
  def schema
37
38
  super.tap do |schema|
38
- schema[:items] = Property.new(@name, inner_type, {}).build
39
+ # Pass ref constraint to items if present (for nested model references)
40
+ item_constraints = @options&.slice(:ref) || {}
41
+ schema[:items] = Property.new(@name, inner_type, item_constraints).build
39
42
  end
40
43
  end
41
44
 
@@ -2,19 +2,32 @@
2
2
 
3
3
  module EasyTalk
4
4
  class Configuration
5
- attr_accessor :exclude_foreign_keys, :exclude_associations, :excluded_columns,
6
- :exclude_primary_key, :exclude_timestamps, :default_additional_properties,
7
- :nilable_is_optional, :auto_validations
5
+ # JSON Schema draft version URIs
6
+ SCHEMA_VERSIONS = {
7
+ draft202012: 'https://json-schema.org/draft/2020-12/schema',
8
+ draft201909: 'https://json-schema.org/draft/2019-09/schema',
9
+ draft7: 'http://json-schema.org/draft-07/schema#',
10
+ draft6: 'http://json-schema.org/draft-06/schema#',
11
+ draft4: 'http://json-schema.org/draft-04/schema#'
12
+ }.freeze
13
+
14
+ attr_accessor :default_additional_properties, :nilable_is_optional, :auto_validations, :schema_version, :schema_id,
15
+ :use_refs
8
16
 
9
17
  def initialize
10
- @exclude_foreign_keys = true
11
- @exclude_associations = true
12
- @excluded_columns = []
13
- @exclude_primary_key = true
14
- @exclude_timestamps = true
15
18
  @default_additional_properties = false
16
19
  @nilable_is_optional = false
17
- @auto_validations = true # New option: enable validations by default
20
+ @auto_validations = true
21
+ @schema_version = :none
22
+ @schema_id = nil
23
+ @use_refs = false
24
+ end
25
+
26
+ # Returns the URI for the configured schema version, or nil if :none
27
+ def schema_uri
28
+ return nil if @schema_version == :none
29
+
30
+ SCHEMA_VERSIONS[@schema_version] || @schema_version.to_s
18
31
  end
19
32
  end
20
33
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module EasyTalk
4
4
  KEYWORDS = %i[
5
+ schema_id
6
+ schema_version
5
7
  description
6
8
  type
7
9
  title
@@ -9,7 +9,6 @@ require 'active_support/json'
9
9
  require 'active_model'
10
10
  require_relative 'builders/object_builder'
11
11
  require_relative 'schema_definition'
12
- require_relative 'active_record_schema_builder'
13
12
  require_relative 'validation_builder'
14
13
 
15
14
  module EasyTalk
@@ -36,16 +35,12 @@ module EasyTalk
36
35
  # @see SchemaDefinition
37
36
  module Model
38
37
  def self.included(base)
39
- base.include ActiveModel::API # Include ActiveModel::API in the class including EasyTalk::Model
38
+ base.extend(ClassMethods)
39
+
40
+ base.include ActiveModel::API
40
41
  base.include ActiveModel::Validations
41
42
  base.extend ActiveModel::Callbacks
42
- base.extend(ClassMethods)
43
43
  base.include(InstanceMethods)
44
-
45
- # Apply ActiveRecord-specific functionality if appropriate
46
- return unless defined?(ActiveRecord) && base.ancestors.include?(ActiveRecord::Base)
47
-
48
- base.extend(ActiveRecordClassMethods)
49
44
  end
50
45
 
51
46
  # Instance methods mixed into models that include EasyTalk::Model
@@ -55,12 +50,7 @@ module EasyTalk
55
50
  super # Perform initial mass assignment
56
51
 
57
52
  # After initial assignment, instantiate nested EasyTalk::Model objects
58
- # Get the appropriate schema definition based on model type
59
- schema_def = if self.class.respond_to?(:active_record_schema_definition)
60
- self.class.active_record_schema_definition
61
- else
62
- self.class.schema_definition
63
- end
53
+ schema_def = self.class.schema_definition
64
54
 
65
55
  # Only proceed if we have a valid schema definition
66
56
  return unless schema_def.respond_to?(:schema) && schema_def.schema.is_a?(Hash)
@@ -69,6 +59,11 @@ module EasyTalk
69
59
  # Get the defined type and the currently assigned value
70
60
  defined_type = prop_definition[:type]
71
61
  current_value = public_send(prop_name)
62
+ nilable_type = defined_type.respond_to?(:nilable?) && defined_type.nilable?
63
+
64
+ next if nilable_type && current_value.nil?
65
+
66
+ defined_type = T::Utils::Nilable.get_underlying_type(defined_type) if nilable_type
72
67
 
73
68
  # Check if the type is another EasyTalk::Model and the value is a Hash
74
69
  next unless defined_type.is_a?(Class) && defined_type.include?(EasyTalk::Model) && current_value.is_a?(Hash)
@@ -116,6 +111,11 @@ module EasyTalk
116
111
  to_hash.merge(@additional_properties)
117
112
  end
118
113
 
114
+ # to_h includes both defined and additional properties
115
+ def to_h
116
+ to_hash.merge(@additional_properties)
117
+ end
118
+
119
119
  # Allow comparison with hashes
120
120
  def ==(other)
121
121
  case other
@@ -141,13 +141,8 @@ module EasyTalk
141
141
  # @return [Schema] The schema for the model.
142
142
  def schema
143
143
  @schema ||= if defined?(@schema_definition) && @schema_definition
144
- # Schema defined explicitly via define_schema
145
144
  build_schema(@schema_definition)
146
- elsif respond_to?(:active_record_schema_definition)
147
- # ActiveRecord model without explicit schema definition
148
- build_schema(active_record_schema_definition)
149
145
  else
150
- # Default case - empty schema
151
146
  {}
152
147
  end
153
148
  end
@@ -160,12 +155,63 @@ module EasyTalk
160
155
  end
161
156
 
162
157
  # Returns the JSON schema for the model.
158
+ # This is the final output that includes the $schema keyword if configured.
163
159
  #
164
160
  # @return [Hash] The JSON schema for the model.
165
161
  def json_schema
166
- @json_schema ||= schema.as_json
162
+ @json_schema ||= build_json_schema
167
163
  end
168
164
 
165
+ private
166
+
167
+ # Builds the final JSON schema with optional $schema and $id keywords.
168
+ def build_json_schema
169
+ result = schema.as_json
170
+ schema_uri = resolve_schema_uri
171
+ id_uri = resolve_schema_id
172
+
173
+ # Build prefix hash with $schema and $id (in that order per JSON Schema convention)
174
+ prefix = {}
175
+ prefix['$schema'] = schema_uri if schema_uri
176
+ prefix['$id'] = id_uri if id_uri
177
+
178
+ return result if prefix.empty?
179
+
180
+ prefix.merge(result)
181
+ end
182
+
183
+ # Resolves the schema URI from per-model setting or global config.
184
+ def resolve_schema_uri
185
+ model_version = @schema_definition&.schema&.dig(:schema_version)
186
+
187
+ if model_version
188
+ # Per-model override - :none means explicitly no $schema
189
+ return nil if model_version == :none
190
+
191
+ Configuration::SCHEMA_VERSIONS[model_version] || model_version.to_s
192
+ else
193
+ # Fall back to global configuration
194
+ EasyTalk.configuration.schema_uri
195
+ end
196
+ end
197
+
198
+ # Resolves the schema ID from per-model setting or global config.
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
205
+
206
+ model_id.to_s
207
+ else
208
+ # Fall back to global configuration
209
+ EasyTalk.configuration.schema_id
210
+ end
211
+ end
212
+
213
+ public
214
+
169
215
  # Define the schema for the model using the provided block.
170
216
  #
171
217
  # @yield The block to define the schema.
@@ -225,34 +271,5 @@ module EasyTalk
225
271
  Builders::ObjectBuilder.new(schema_definition).build
226
272
  end
227
273
  end
228
-
229
- # Module containing ActiveRecord-specific methods for schema generation
230
- module ActiveRecordClassMethods
231
- # Gets a SchemaDefinition that's built from the ActiveRecord database schema
232
- #
233
- # @return [SchemaDefinition] A schema definition built from the database
234
- def active_record_schema_definition
235
- @active_record_schema_definition ||= ActiveRecordSchemaBuilder.new(self).build_schema_definition
236
- end
237
-
238
- # Store enhancements to be applied to the schema
239
- #
240
- # @return [Hash] The schema enhancements
241
- def schema_enhancements
242
- @schema_enhancements ||= {}
243
- end
244
-
245
- # Enhance the generated schema with additional information
246
- #
247
- # @param enhancements [Hash] The schema enhancements
248
- # @return [void]
249
- def enhance_schema(enhancements)
250
- @schema_enhancements = enhancements
251
- # Clear cached values to force regeneration
252
- @active_record_schema_definition = nil
253
- @schema = nil
254
- @json_schema = nil
255
- end
256
- end
257
274
  end
258
275
  end
@@ -101,22 +101,31 @@ module EasyTalk
101
101
  # This method handles different types of properties:
102
102
  # - Nilable types (can be null)
103
103
  # - Types with dedicated builders
104
- # - Types that implement their own schema method
104
+ # - Types that implement their own schema method (EasyTalk models)
105
105
  # - Default fallback to 'object' type
106
106
  #
107
+ # When use_refs is enabled (globally or per-property), EasyTalk models
108
+ # are referenced via $ref instead of being inlined.
109
+ #
107
110
  # @return [Hash] The complete JSON Schema property definition
108
111
  #
109
112
  # @example Simple string property
110
113
  # property = Property.new(:name, 'String')
111
114
  # property.build # => {"type"=>"string"}
112
115
  #
113
- # @example Complex nested schema
116
+ # @example Complex nested schema (inlined)
114
117
  # address = Address.new # A class with a .schema method
115
118
  # property = Property.new(:shipping_address, address, description: "Shipping address")
116
119
  # property.build # => Address schema merged with the description constraint
120
+ #
121
+ # @example Nested schema with $ref
122
+ # property = Property.new(:shipping_address, Address, ref: true)
123
+ # property.build # => {"$ref"=>"#/$defs/Address", ...constraints}
117
124
  def build
118
125
  if nilable_type?
119
126
  build_nilable_schema
127
+ elsif should_use_ref?
128
+ build_ref_schema
120
129
  elsif builder
121
130
  args = builder.collection_type? ? [name, type, constraints] : [name, constraints]
122
131
  builder.new(*args).build
@@ -189,10 +198,18 @@ module EasyTalk
189
198
  # {"type"=>["string", "null"]}
190
199
  def build_nilable_schema
191
200
  # Extract the non-nil type from the Union
192
- actual_type = type.types.find { |t| t.raw_type != NilClass }
201
+ actual_type = T::Utils::Nilable.get_underlying_type(type)
193
202
 
194
203
  return { type: 'null' } unless actual_type
195
204
 
205
+ # Check if the underlying type is an EasyTalk model that should use $ref
206
+ if easytalk_model?(actual_type) && should_use_ref_for_type?(actual_type)
207
+ # Use anyOf with $ref and null type
208
+ ref_constraints = constraints.except(:ref, :optional)
209
+ schema = { anyOf: [{ '$ref': actual_type.ref_template }, { type: 'null' }] }
210
+ return ref_constraints.empty? ? schema : schema.merge(ref_constraints)
211
+ end
212
+
196
213
  # Create a property with the actual type
197
214
  non_nil_schema = Property.new(name, actual_type, constraints).build
198
215
 
@@ -201,5 +218,54 @@ module EasyTalk
201
218
  type: [non_nil_schema[:type], 'null'].compact
202
219
  )
203
220
  end
221
+
222
+ # Determines if $ref should be used for the current type.
223
+ #
224
+ # @return [Boolean] true if $ref should be used, false otherwise
225
+ # @api private
226
+ def should_use_ref?
227
+ return false unless easytalk_model?(type)
228
+
229
+ should_use_ref_for_type?(type)
230
+ end
231
+
232
+ # Determines if $ref should be used for a given type based on constraints and config.
233
+ #
234
+ # @param check_type [Class] The type to check
235
+ # @return [Boolean] true if $ref should be used, false otherwise
236
+ # @api private
237
+ def should_use_ref_for_type?(check_type)
238
+ return false unless easytalk_model?(check_type)
239
+
240
+ # Per-property constraint takes precedence
241
+ return constraints[:ref] if constraints.key?(:ref)
242
+
243
+ # Fall back to global configuration
244
+ EasyTalk.configuration.use_refs
245
+ end
246
+
247
+ # Checks if a type is an EasyTalk model.
248
+ #
249
+ # @param check_type [Object] The type to check
250
+ # @return [Boolean] true if the type is an EasyTalk model
251
+ # @api private
252
+ def easytalk_model?(check_type)
253
+ check_type.is_a?(Class) &&
254
+ check_type.respond_to?(:schema) &&
255
+ check_type.respond_to?(:ref_template) &&
256
+ defined?(EasyTalk::Model) &&
257
+ check_type.include?(EasyTalk::Model)
258
+ end
259
+
260
+ # Builds a $ref schema for an EasyTalk model.
261
+ #
262
+ # @return [Hash] A schema with $ref pointing to the model's definition
263
+ # @api private
264
+ def build_ref_schema
265
+ # Remove ref and optional from constraints as they're not JSON Schema keywords
266
+ ref_constraints = constraints.except(:ref, :optional)
267
+ schema = { '$ref': type.ref_template }
268
+ ref_constraints.empty? ? schema : schema.merge(ref_constraints)
269
+ end
204
270
  end
205
271
  end
@@ -65,8 +65,8 @@ module EasyTalk
65
65
  end
66
66
 
67
67
  # Check if the type is nilable (e.g., T.nilable(String))
68
- def nilable_type?
69
- @type.respond_to?(:nilable?) && @type.nilable?
68
+ def nilable_type?(type = @type)
69
+ type.respond_to?(:nilable?) && type.nilable?
70
70
  end
71
71
 
72
72
  # Extract the inner type from a complex type like T.nilable(String)
@@ -118,6 +118,8 @@ module EasyTalk
118
118
  end
119
119
  elsif type.to_s.include?('T::Boolean')
120
120
  [TrueClass, FalseClass] # Return both boolean classes
121
+ elsif nilable_type?(type)
122
+ extract_inner_type(type)
121
123
  else
122
124
  String # Default fallback
123
125
  end
@@ -137,48 +139,30 @@ module EasyTalk
137
139
  @klass.validates @property_name, format: { with: Regexp.new(@constraints[:pattern]) } if @constraints[:pattern]
138
140
 
139
141
  # 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?
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
144
150
  end
145
151
 
146
152
  # Apply format-specific validations (email, url, etc.)
147
153
  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
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
182
166
  end
183
167
 
184
168
  # Validate integer-specific constraints
@@ -193,15 +177,19 @@ module EasyTalk
193
177
 
194
178
  # Apply numeric validations for integers and floats
195
179
  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
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
205
193
 
206
194
  # Add multiple_of validation
207
195
  return unless @constraints[:multiple_of]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyTalk
4
- VERSION = '2.0.0'
4
+ VERSION = '3.1.0'
5
5
  end