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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +663 -165
- data/easy_talk.gemspec +5 -4
- data/lib/easy_talk/builders/integer_builder.rb +1 -0
- data/lib/easy_talk/builders/object_builder.rb +95 -1
- data/lib/easy_talk/builders/string_builder.rb +9 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +5 -2
- data/lib/easy_talk/configuration.rb +22 -9
- data/lib/easy_talk/keywords.rb +2 -0
- data/lib/easy_talk/model.rb +66 -49
- data/lib/easy_talk/property.rb +69 -3
- data/lib/easy_talk/validation_builder.rb +37 -49
- data/lib/easy_talk/version.rb +1 -1
- metadata +36 -10
- data/lib/easy_talk/active_record_schema_builder.rb +0 -299
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', '
|
|
34
|
-
spec.add_dependency 'activesupport', '
|
|
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'
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
data/lib/easy_talk/keywords.rb
CHANGED
data/lib/easy_talk/model.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 ||=
|
|
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
|
data/lib/easy_talk/property.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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]
|
data/lib/easy_talk/version.rb
CHANGED