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.
@@ -4,16 +4,17 @@ module EasyTalk
4
4
  class Configuration
5
5
  attr_accessor :exclude_foreign_keys, :exclude_associations, :excluded_columns,
6
6
  :exclude_primary_key, :exclude_timestamps, :default_additional_properties,
7
- :nilable_is_optional
7
+ :nilable_is_optional, :auto_validations
8
8
 
9
9
  def initialize
10
10
  @exclude_foreign_keys = true
11
11
  @exclude_associations = true
12
12
  @excluded_columns = []
13
- @exclude_primary_key = true # New option, defaulting to true
14
- @exclude_timestamps = true # New option, defaulting to true
13
+ @exclude_primary_key = true
14
+ @exclude_timestamps = true
15
15
  @default_additional_properties = false
16
16
  @nilable_is_optional = false
17
+ @auto_validations = true # New option: enable validations by default
17
18
  end
18
19
  end
19
20
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyTalk
4
+ # Helper module for generating consistent error messages
4
5
  module ErrorHelper
5
6
  def self.raise_constraint_error(property_name:, constraint_name:, expected:, got:)
6
7
  message = "Error in property '#{property_name}': Constraint '#{constraint_name}' expects #{expected}, " \
@@ -10,6 +10,7 @@ require 'active_model'
10
10
  require_relative 'builders/object_builder'
11
11
  require_relative 'schema_definition'
12
12
  require_relative 'active_record_schema_builder'
13
+ require_relative 'validation_builder'
13
14
 
14
15
  module EasyTalk
15
16
  # The `Model` module is a mixin that provides functionality for defining and accessing the schema of a model.
@@ -47,10 +48,35 @@ module EasyTalk
47
48
  base.extend(ActiveRecordClassMethods)
48
49
  end
49
50
 
51
+ # Instance methods mixed into models that include EasyTalk::Model
50
52
  module InstanceMethods
51
53
  def initialize(attributes = {})
52
54
  @additional_properties = {}
53
- super
55
+ super # Perform initial mass assignment
56
+
57
+ # 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
64
+
65
+ # Only proceed if we have a valid schema definition
66
+ return unless schema_def.respond_to?(:schema) && schema_def.schema.is_a?(Hash)
67
+
68
+ (schema_def.schema[:properties] || {}).each do |prop_name, prop_definition|
69
+ # Get the defined type and the currently assigned value
70
+ defined_type = prop_definition[:type]
71
+ current_value = public_send(prop_name)
72
+
73
+ # Check if the type is another EasyTalk::Model and the value is a Hash
74
+ next unless defined_type.is_a?(Class) && defined_type.include?(EasyTalk::Model) && current_value.is_a?(Hash)
75
+
76
+ # Instantiate the nested model and assign it back
77
+ nested_instance = defined_type.new(current_value)
78
+ public_send("#{prop_name}=", nested_instance)
79
+ end
54
80
  end
55
81
 
56
82
  def method_missing(method_name, *args)
@@ -77,9 +103,10 @@ module EasyTalk
77
103
 
78
104
  # Add to_hash method to convert defined properties to hash
79
105
  def to_hash
80
- return {} unless self.class.properties
106
+ properties_to_include = (self.class.schema_definition.schema[:properties] || {}).keys
107
+ return {} if properties_to_include.empty?
81
108
 
82
- self.class.properties.each_with_object({}) do |prop, hash|
109
+ properties_to_include.each_with_object({}) do |prop, hash|
83
110
  hash[prop.to_s] = send(prop)
84
111
  end
85
112
  end
@@ -88,6 +115,23 @@ module EasyTalk
88
115
  def as_json(_options = {})
89
116
  to_hash.merge(@additional_properties)
90
117
  end
118
+
119
+ # Allow comparison with hashes
120
+ def ==(other)
121
+ case other
122
+ when Hash
123
+ # Convert both to comparable format for comparison
124
+ self_hash = (self.class.schema_definition.schema[:properties] || {}).keys.each_with_object({}) do |prop, hash|
125
+ hash[prop] = send(prop)
126
+ end
127
+
128
+ # Handle both symbol and string keys in the other hash
129
+ other_normalized = other.transform_keys(&:to_sym)
130
+ self_hash == other_normalized
131
+ else
132
+ super
133
+ end
134
+ end
91
135
  end
92
136
 
93
137
  # Module containing class-level methods for defining and accessing the schema of a model.
@@ -115,14 +159,6 @@ module EasyTalk
115
159
  "#/$defs/#{name}"
116
160
  end
117
161
 
118
- def properties
119
- @properties ||= begin
120
- return unless schema[:properties].present?
121
-
122
- schema[:properties].keys.map(&:to_sym)
123
- end
124
- end
125
-
126
162
  # Returns the JSON schema for the model.
127
163
  #
128
164
  # @return [Hash] The JSON schema for the model.
@@ -134,12 +170,30 @@ module EasyTalk
134
170
  #
135
171
  # @yield The block to define the schema.
136
172
  # @raise [ArgumentError] If the class does not have a name.
137
- def define_schema(&block)
173
+ def define_schema(&)
138
174
  raise ArgumentError, 'The class must have a name' unless name.present?
139
175
 
140
176
  @schema_definition = SchemaDefinition.new(name)
141
- @schema_definition.instance_eval(&block)
142
- attr_accessor(*properties)
177
+ @schema_definition.klass = self # Pass the model class to the schema definition
178
+ @schema_definition.instance_eval(&)
179
+
180
+ # Define accessors immediately based on schema_definition
181
+ defined_properties = (@schema_definition.schema[:properties] || {}).keys
182
+ attr_accessor(*defined_properties)
183
+
184
+ # Track which properties have had validations applied
185
+ @validated_properties ||= Set.new
186
+
187
+ # Apply auto-validations immediately after definition
188
+ if EasyTalk.configuration.auto_validations
189
+ (@schema_definition.schema[:properties] || {}).each do |prop_name, prop_def|
190
+ # Only apply validations if they haven't been applied yet
191
+ unless @validated_properties.include?(prop_name)
192
+ ValidationBuilder.build_validations(self, prop_name, prop_def[:type], prop_def[:constraints])
193
+ @validated_properties.add(prop_name)
194
+ end
195
+ end
196
+ end
143
197
 
144
198
  @schema_definition
145
199
  end
@@ -155,6 +209,13 @@ module EasyTalk
155
209
  @schema_definition&.schema&.fetch(:additional_properties, false)
156
210
  end
157
211
 
212
+ # Returns the property names defined in the schema
213
+ #
214
+ # @return [Array<Symbol>] Array of property names as symbols
215
+ def properties
216
+ (@schema_definition&.schema&.dig(:properties) || {}).keys
217
+ end
218
+
158
219
  # Builds the schema using the provided schema definition.
159
220
  # This is the convergence point for all schema generation.
160
221
  #
@@ -11,30 +11,51 @@ require_relative 'builders/composition_builder'
11
11
  require_relative 'builders/typed_array_builder'
12
12
  require_relative 'builders/union_builder'
13
13
 
14
- # frozen_string_literal: true
15
-
16
- # EasyTalk module provides classes for building JSON schema properties.
14
+ # EasyTalk module provides a DSL for building JSON Schema definitions.
15
+ #
16
+ # This module contains classes and utilities for easily creating valid JSON Schema
17
+ # documents with a Ruby-native syntax. The `Property` class serves as the main entry
18
+ # point for defining schema properties.
17
19
  #
18
- # This module contains the `Property` class, which is used to build a JSON schema property.
19
- # It also defines a constant `TYPE_TO_BUILDER` which maps property types to their respective builders.
20
+ # @example Basic property definition
21
+ # property = EasyTalk::Property.new(:name, String, minLength: 3, maxLength: 50)
22
+ # property.build # => {"type"=>"string", "minLength"=>3, "maxLength"=>50}
20
23
  #
21
- # Example usage:
22
- # property = EasyTalk::Property.new(:name, 'String', minLength: 3, maxLength: 50)
23
- # property.build
24
+ # @example Using with nilable types
25
+ # nilable_prop = EasyTalk::Property.new(:optional_field, T::Types::Union.new(String, NilClass))
26
+ # nilable_prop.build # => {"type"=>["string", "null"]}
24
27
  #
25
28
  # @see EasyTalk::Property
29
+ # @see https://json-schema.org/ JSON Schema Documentation
26
30
  module EasyTalk
27
31
  # Property class for building a JSON schema property.
32
+ #
33
+ # This class handles the conversion from Ruby types to JSON Schema property definitions,
34
+ # and provides support for common constraints like minimum/maximum values, string patterns,
35
+ # and custom validators.
28
36
  class Property
29
37
  extend T::Sig
30
- attr_reader :name, :type, :constraints
31
38
 
39
+ # @return [Symbol] The name of the property
40
+ attr_reader :name
41
+
42
+ # @return [Object] The type definition of the property
43
+ attr_reader :type
44
+
45
+ # @return [Hash<Symbol, Object>] Additional constraints applied to the property
46
+ attr_reader :constraints
47
+
48
+ # Mapping of Ruby type names to their corresponding schema builder classes.
49
+ # Each builder knows how to convert a specific Ruby type to JSON Schema.
50
+ #
51
+ # @api private
32
52
  TYPE_TO_BUILDER = {
33
53
  'String' => Builders::StringBuilder,
34
54
  'Integer' => Builders::IntegerBuilder,
35
55
  'Float' => Builders::NumberBuilder,
36
56
  'BigDecimal' => Builders::NumberBuilder,
37
57
  'T::Boolean' => Builders::BooleanBuilder,
58
+ 'TrueClass' => Builders::BooleanBuilder,
38
59
  'NilClass' => Builders::NullBuilder,
39
60
  'Date' => Builders::TemporalBuilder::DateBuilder,
40
61
  'DateTime' => Builders::TemporalBuilder::DatetimeBuilder,
@@ -47,10 +68,19 @@ module EasyTalk
47
68
  }.freeze
48
69
 
49
70
  # Initializes a new instance of the Property class.
50
- # @param name [Symbol] The name of the property.
51
- # @param type [Object] The type of the property.
52
- # @param constraints [Hash] The property constraints.
53
- # @raise [ArgumentError] If the property type is missing.
71
+ #
72
+ # @param name [Symbol] The name of the property
73
+ # @param type [Object] The type of the property (Ruby class, string name, or Sorbet type)
74
+ # @param constraints [Hash<Symbol, Object>] Additional constraints for the property
75
+ # (e.g., minLength, pattern, format)
76
+ #
77
+ # @example String property with constraints
78
+ # Property.new(:username, 'String', minLength: 3, maxLength: 20, pattern: '^[a-z0-9_]+$')
79
+ #
80
+ # @example Integer property with range
81
+ # Property.new(:age, 'Integer', minimum: 0, maximum: 120)
82
+ #
83
+ # @raise [ArgumentError] If the property type is missing or empty
54
84
  sig do
55
85
  params(name: Symbol, type: T.any(String, Object),
56
86
  constraints: T::Hash[Symbol, T.untyped]).void
@@ -59,18 +89,31 @@ module EasyTalk
59
89
  @name = name
60
90
  @type = type
61
91
  @constraints = constraints
62
- raise ArgumentError, 'property type is missing' if type.blank?
92
+ if type.nil? || (type.respond_to?(:empty?) && type.is_a?(String) && type.strip.empty?)
93
+ raise ArgumentError,
94
+ 'property type is missing'
95
+ end
96
+ raise ArgumentError, 'property type is not supported' if type.is_a?(Array) && type.empty?
63
97
  end
64
98
 
65
- # Builds the property based on the specified type, constraints, and builder.
99
+ # Builds the property schema based on its type and constraints.
66
100
  #
67
- # If the type responds to the `schema` method, it returns the schema of the type.
68
- # Otherwise, it returns 'object'.
101
+ # This method handles different types of properties:
102
+ # - Nilable types (can be null)
103
+ # - Types with dedicated builders
104
+ # - Types that implement their own schema method
105
+ # - Default fallback to 'object' type
69
106
  #
70
- # If a builder is specified, it uses the builder to build the property.
71
- # The arguments passed to the builder depend on whether the builder is a collection type or not.
107
+ # @return [Hash] The complete JSON Schema property definition
72
108
  #
73
- # @return [Object] The built property.
109
+ # @example Simple string property
110
+ # property = Property.new(:name, 'String')
111
+ # property.build # => {"type"=>"string"}
112
+ #
113
+ # @example Complex nested schema
114
+ # address = Address.new # A class with a .schema method
115
+ # property = Property.new(:shipping_address, address, description: "Shipping address")
116
+ # property.build # => Address schema merged with the description constraint
74
117
  def build
75
118
  if nilable_type?
76
119
  build_nilable_schema
@@ -86,43 +129,76 @@ module EasyTalk
86
129
  end
87
130
  end
88
131
 
89
- # Converts the object to a JSON representation.
132
+ # Converts the property definition to a JSON-compatible format.
133
+ #
134
+ # This method enables seamless integration with Ruby's JSON library.
135
+ #
136
+ # @param _args [Array] Optional arguments passed to #as_json (ignored)
137
+ # @return [Hash] The JSON-compatible representation of the property schema
90
138
  #
91
- # @param _args [Array] Optional arguments
92
- # @return [Hash] The JSON representation of the object
139
+ # @see #build
140
+ # @see https://ruby-doc.org/stdlib-2.7.2/libdoc/json/rdoc/JSON.html#as_json-method
93
141
  def as_json(*_args)
94
142
  build.as_json
95
143
  end
96
144
 
97
- # Returns the builder associated with the property type.
145
+ # Returns the builder class associated with the property type.
98
146
  #
99
- # The builder is responsible for constructing the property based on its type.
100
- # It looks up the builder based on the type's class name or name.
147
+ # The builder is responsible for converting the Ruby type to a JSON Schema type.
101
148
  #
102
- # @return [Builder] The builder associated with the property type.
149
+ # @return [Class, nil] The builder class for this property's type, or nil if no dedicated builder exists
150
+ # @see #find_builder_for_type
103
151
  def builder
104
- @builder ||= TYPE_TO_BUILDER[type.class.name.to_s] || TYPE_TO_BUILDER[type.name.to_s]
152
+ @builder ||= find_builder_for_type
105
153
  end
106
154
 
107
155
  private
108
156
 
157
+ # Finds the appropriate builder for the current type.
158
+ #
159
+ # First checks if there's a builder for the class name, then falls back
160
+ # to checking if there's a builder for the type's name (if it responds to :name).
161
+ #
162
+ # @return [Class, nil] The builder class for this type, or nil if none matches
163
+ # @api private
164
+ def find_builder_for_type
165
+ TYPE_TO_BUILDER[type.class.name.to_s] ||
166
+ (type.respond_to?(:name) ? TYPE_TO_BUILDER[type.name.to_s] : nil)
167
+ end
168
+
169
+ # Determines if the type is nilable (can be nil).
170
+ #
171
+ # A type is nilable if it's a union type that includes NilClass.
172
+ # This is typically represented as T.nilable(Type) in Sorbet.
173
+ #
174
+ # @return [Boolean] true if the type is nilable, false otherwise
175
+ # @api private
109
176
  def nilable_type?
110
- return unless type.respond_to?(:types)
111
- return unless type.types.all? { |t| t.respond_to?(:raw_type) }
177
+ return false unless type.respond_to?(:types)
178
+ return false unless type.types.all? { |t| t.respond_to?(:raw_type) }
112
179
 
113
180
  type.types.any? { |t| t.raw_type == NilClass }
114
181
  end
115
182
 
183
+ # Builds a schema for a nilable type, which can be either the actual type or null.
184
+ #
185
+ # @return [Hash] A schema with both the actual type and null type
186
+ # @api private
187
+ # @example
188
+ # # For a T.nilable(String) type:
189
+ # {"type"=>["string", "null"]}
116
190
  def build_nilable_schema
117
191
  # Extract the non-nil type from the Union
118
- actual_type = type.types.find { |t| t != NilClass }
192
+ actual_type = type.types.find { |t| t.raw_type != NilClass }
193
+
194
+ return { type: 'null' } unless actual_type
119
195
 
120
196
  # Create a property with the actual type
121
197
  non_nil_schema = Property.new(name, actual_type, constraints).build
122
198
 
123
199
  # Merge the types into an array
124
200
  non_nil_schema.merge(
125
- type: [non_nil_schema[:type], 'null']
201
+ type: [non_nil_schema[:type], 'null'].compact
126
202
  )
127
203
  end
128
204
  end
@@ -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