easy_talk 3.2.0 → 3.3.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/.rubocop.yml +15 -43
- data/CHANGELOG.md +89 -0
- data/README.md +447 -2115
- data/docs/json_schema_compliance.md +140 -26
- data/docs/primitive-schema-rfc.md +894 -0
- data/lib/easy_talk/builders/base_builder.rb +2 -1
- data/lib/easy_talk/builders/boolean_builder.rb +2 -1
- data/lib/easy_talk/builders/collection_helpers.rb +4 -0
- data/lib/easy_talk/builders/composition_builder.rb +7 -2
- data/lib/easy_talk/builders/integer_builder.rb +2 -1
- data/lib/easy_talk/builders/null_builder.rb +4 -1
- data/lib/easy_talk/builders/number_builder.rb +4 -1
- data/lib/easy_talk/builders/object_builder.rb +64 -3
- data/lib/easy_talk/builders/registry.rb +15 -1
- data/lib/easy_talk/builders/string_builder.rb +3 -1
- data/lib/easy_talk/builders/temporal_builder.rb +7 -0
- data/lib/easy_talk/builders/tuple_builder.rb +89 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +4 -2
- data/lib/easy_talk/builders/union_builder.rb +5 -1
- data/lib/easy_talk/configuration.rb +17 -2
- data/lib/easy_talk/errors.rb +1 -0
- data/lib/easy_talk/errors_helper.rb +3 -0
- data/lib/easy_talk/json_schema_equality.rb +46 -0
- data/lib/easy_talk/keywords.rb +0 -1
- data/lib/easy_talk/model.rb +27 -1
- data/lib/easy_talk/model_helper.rb +4 -0
- data/lib/easy_talk/naming_strategies.rb +4 -0
- data/lib/easy_talk/property.rb +7 -0
- data/lib/easy_talk/ref_helper.rb +6 -0
- data/lib/easy_talk/schema.rb +1 -0
- data/lib/easy_talk/schema_definition.rb +52 -6
- data/lib/easy_talk/schema_methods.rb +36 -5
- data/lib/easy_talk/sorbet_extension.rb +1 -0
- data/lib/easy_talk/type_introspection.rb +45 -1
- data/lib/easy_talk/types/tuple.rb +77 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +350 -62
- data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
- data/lib/easy_talk/validation_adapters/base.rb +12 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +9 -0
- data/lib/easy_talk/validation_builder.rb +1 -0
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +1 -0
- metadata +13 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyTalk
|
|
4
|
+
# Implements JSON Schema equality semantics for comparing values.
|
|
5
|
+
#
|
|
6
|
+
# Per JSON Schema specification:
|
|
7
|
+
# - Objects with same keys/values in different order are equal
|
|
8
|
+
# - Numbers that are mathematically equal are equal (1 == 1.0)
|
|
9
|
+
# - Type matters for non-numbers (true != 1, false != 0)
|
|
10
|
+
module JsonSchemaEquality
|
|
11
|
+
# Maximum nesting depth to prevent SystemStackError on deeply nested structures
|
|
12
|
+
MAX_DEPTH = 100
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Check if an array contains duplicate values using JSON Schema equality.
|
|
16
|
+
# Uses a Set for O(n) performance and early termination on first duplicate.
|
|
17
|
+
def duplicates?(array)
|
|
18
|
+
seen = Set.new
|
|
19
|
+
array.any? { |item| !seen.add?(normalize(item)) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Normalize a value for JSON Schema equality comparison
|
|
23
|
+
# @param value [Object] The value to normalize
|
|
24
|
+
# @param depth [Integer] Current recursion depth (for stack overflow protection)
|
|
25
|
+
# @raise [ArgumentError] if nesting depth exceeds MAX_DEPTH
|
|
26
|
+
def normalize(value, depth = 0)
|
|
27
|
+
raise ArgumentError, "Nesting depth exceeds maximum of #{MAX_DEPTH}" if depth > MAX_DEPTH
|
|
28
|
+
|
|
29
|
+
case value
|
|
30
|
+
when Hash
|
|
31
|
+
# Convert keys to strings before sorting to handle mixed key types (Symbol/String)
|
|
32
|
+
# and ensure consistent, order-independent comparison (JSON only has string keys)
|
|
33
|
+
value.map { |k, v| [k.to_s, normalize(v, depth + 1)] }.sort
|
|
34
|
+
when Array
|
|
35
|
+
value.map { |item| normalize(item, depth + 1) }
|
|
36
|
+
when Integer, Float
|
|
37
|
+
# Normalize numbers to a canonical form for mathematical equality
|
|
38
|
+
value.to_r
|
|
39
|
+
else
|
|
40
|
+
# Booleans, strings, nil - preserve as-is (type matters)
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/easy_talk/keywords.rb
CHANGED
data/lib/easy_talk/model.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
require 'json'
|
|
4
5
|
require 'active_support'
|
|
@@ -220,6 +221,9 @@ module EasyTalk
|
|
|
220
221
|
# Track which properties have had validations applied
|
|
221
222
|
@validated_properties ||= Set.new
|
|
222
223
|
|
|
224
|
+
# Initialize mutex eagerly for thread-safe schema-level validation application
|
|
225
|
+
@schema_level_validation_lock = Mutex.new
|
|
226
|
+
|
|
223
227
|
# Apply validations using the adapter system
|
|
224
228
|
apply_schema_validations
|
|
225
229
|
|
|
@@ -273,6 +277,26 @@ module EasyTalk
|
|
|
273
277
|
adapter.build_validations(self, prop_name, prop_def[:type], prop_def[:constraints])
|
|
274
278
|
@validated_properties.add(prop_name)
|
|
275
279
|
end
|
|
280
|
+
|
|
281
|
+
# Apply schema-level validations (min_properties, max_properties, dependent_required)
|
|
282
|
+
apply_schema_level_validations(adapter)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Apply schema-level validations for object-level constraints.
|
|
286
|
+
# Uses double-checked locking for thread safety.
|
|
287
|
+
# The mutex is initialized eagerly in define_schema.
|
|
288
|
+
#
|
|
289
|
+
# @param adapter [Class] The validation adapter class
|
|
290
|
+
# @return [void]
|
|
291
|
+
def apply_schema_level_validations(adapter)
|
|
292
|
+
return if @schema_level_validations_applied
|
|
293
|
+
|
|
294
|
+
@schema_level_validation_lock.synchronize do
|
|
295
|
+
return if @schema_level_validations_applied
|
|
296
|
+
|
|
297
|
+
adapter.build_schema_validations(self, @schema_definition.schema)
|
|
298
|
+
@schema_level_validations_applied = true
|
|
299
|
+
end
|
|
276
300
|
end
|
|
277
301
|
|
|
278
302
|
public
|
|
@@ -285,7 +309,9 @@ module EasyTalk
|
|
|
285
309
|
end
|
|
286
310
|
|
|
287
311
|
def additional_properties_allowed?
|
|
288
|
-
@schema_definition&.schema&.fetch(:additional_properties, false)
|
|
312
|
+
ap = @schema_definition&.schema&.fetch(:additional_properties, false)
|
|
313
|
+
# Allow if true, or if it's a schema object (Class or Hash with type)
|
|
314
|
+
ap == true || ap.is_a?(Class) || ap.is_a?(Hash)
|
|
289
315
|
end
|
|
290
316
|
|
|
291
317
|
# Returns the property names defined in the schema
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
module EasyTalk
|
|
4
5
|
module NamingStrategies
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
5
8
|
IDENTITY = lambda(&:to_sym)
|
|
6
9
|
SNAKE_CASE = ->(property_name) { property_name.to_s.underscore.to_sym }
|
|
7
10
|
CAMEL_CASE = ->(property_name) { property_name.to_s.tr('-', '_').camelize(:lower).to_sym }
|
|
8
11
|
PASCAL_CASE = ->(property_name) { property_name.to_s.tr('-', '_').camelize.to_sym }
|
|
9
12
|
|
|
13
|
+
sig { params(strategy: T.any(Symbol, T.proc.params(arg0: T.untyped).returns(Symbol))).returns(T.proc.params(arg0: T.untyped).returns(Symbol)) }
|
|
10
14
|
def self.derive_strategy(strategy)
|
|
11
15
|
if strategy.is_a?(Symbol)
|
|
12
16
|
"EasyTalk::NamingStrategies::#{strategy.to_s.upcase}".constantize
|
data/lib/easy_talk/property.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
require 'json'
|
|
4
5
|
require_relative 'builders/integer_builder'
|
|
@@ -9,6 +10,7 @@ require_relative 'builders/string_builder'
|
|
|
9
10
|
require_relative 'builders/temporal_builder'
|
|
10
11
|
require_relative 'builders/composition_builder'
|
|
11
12
|
require_relative 'builders/typed_array_builder'
|
|
13
|
+
require_relative 'builders/tuple_builder'
|
|
12
14
|
require_relative 'builders/union_builder'
|
|
13
15
|
|
|
14
16
|
# EasyTalk module provides a DSL for building JSON Schema definitions.
|
|
@@ -99,6 +101,7 @@ module EasyTalk
|
|
|
99
101
|
# @example Nested schema with $ref
|
|
100
102
|
# property = Property.new(:shipping_address, Address, ref: true)
|
|
101
103
|
# property.build # => {"$ref"=>"#/$defs/Address", ...constraints}
|
|
104
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
102
105
|
def build
|
|
103
106
|
if nilable_type?
|
|
104
107
|
build_nilable_schema
|
|
@@ -128,6 +131,7 @@ module EasyTalk
|
|
|
128
131
|
#
|
|
129
132
|
# @see #build
|
|
130
133
|
# @see https://ruby-doc.org/stdlib-2.7.2/libdoc/json/rdoc/JSON.html#as_json-method
|
|
134
|
+
sig { params(_args: T.untyped).returns(T.untyped) }
|
|
131
135
|
def as_json(*_args)
|
|
132
136
|
build.as_json
|
|
133
137
|
end
|
|
@@ -142,6 +146,7 @@ module EasyTalk
|
|
|
142
146
|
# @return [Array(Class, Boolean), nil] A tuple of [builder_class, is_collection] or nil if none matches
|
|
143
147
|
# @api private
|
|
144
148
|
# @see Builders::Registry.resolve
|
|
149
|
+
sig { returns(T.nilable(T::Array[T.untyped])) }
|
|
145
150
|
def find_builder_for_type
|
|
146
151
|
Builders::Registry.resolve(type)
|
|
147
152
|
end
|
|
@@ -153,6 +158,7 @@ module EasyTalk
|
|
|
153
158
|
#
|
|
154
159
|
# @return [Boolean] true if the type is nilable, false otherwise
|
|
155
160
|
# @api private
|
|
161
|
+
sig { returns(T::Boolean) }
|
|
156
162
|
def nilable_type?
|
|
157
163
|
return false unless type.respond_to?(:types)
|
|
158
164
|
return false unless type.types.all? { |t| t.respond_to?(:raw_type) }
|
|
@@ -167,6 +173,7 @@ module EasyTalk
|
|
|
167
173
|
# @example
|
|
168
174
|
# # For a T.nilable(String) type:
|
|
169
175
|
# {"type"=>["string", "null"]}
|
|
176
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
170
177
|
def build_nilable_schema
|
|
171
178
|
# Extract the non-nil type from the Union
|
|
172
179
|
actual_type = T::Utils::Nilable.get_underlying_type(type)
|
data/lib/easy_talk/ref_helper.rb
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
require_relative 'model_helper'
|
|
4
5
|
|
|
5
6
|
module EasyTalk
|
|
6
7
|
module RefHelper
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
|
7
11
|
def self.should_use_ref?(type, constraints)
|
|
8
12
|
ModelHelper.easytalk_model?(type) && should_use_ref_for_type?(type, constraints)
|
|
9
13
|
end
|
|
10
14
|
|
|
15
|
+
sig { params(type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
|
11
16
|
def self.should_use_ref_for_type?(type, constraints)
|
|
12
17
|
return false unless ModelHelper.easytalk_model?(type)
|
|
13
18
|
|
|
@@ -19,6 +24,7 @@ module EasyTalk
|
|
|
19
24
|
end
|
|
20
25
|
end
|
|
21
26
|
|
|
27
|
+
sig { params(type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
22
28
|
def self.build_ref_schema(type, constraints)
|
|
23
29
|
# Remove ref and optional from constraints as they're not JSON Schema keywords
|
|
24
30
|
{ '$ref': type.ref_template }.merge(constraints.except(:ref, :optional))
|
data/lib/easy_talk/schema.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
require_relative 'keywords'
|
|
4
5
|
require_relative 'types/composer'
|
|
@@ -19,28 +20,39 @@ module EasyTalk
|
|
|
19
20
|
attr_reader :name, :schema
|
|
20
21
|
attr_accessor :klass # Add accessor for the model class
|
|
21
22
|
|
|
23
|
+
sig { params(name: String, schema: T::Hash[Symbol, T.untyped]).void }
|
|
22
24
|
def initialize(name, schema = {})
|
|
23
|
-
@schema = schema
|
|
24
|
-
@schema[:additional_properties] =
|
|
25
|
+
@schema = schema.dup
|
|
26
|
+
@schema[:additional_properties] = EasyTalk.configuration.default_additional_properties unless @schema.key?(:additional_properties)
|
|
25
27
|
@name = name
|
|
26
28
|
@klass = nil # Initialize klass to nil
|
|
27
29
|
@property_naming_strategy = EasyTalk.configuration.property_naming_strategy
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
EasyTalk::KEYWORDS.each do |keyword|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
if keyword == :additional_properties
|
|
34
|
+
# Special handling for additional_properties to support type + constraints syntax
|
|
35
|
+
define_method(keyword) do |*args|
|
|
36
|
+
value = parse_additional_properties_args(args)
|
|
37
|
+
@schema[keyword] = value
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
define_method(keyword) do |*values|
|
|
41
|
+
@schema[keyword] = values.size > 1 ? values : values.first
|
|
42
|
+
end
|
|
33
43
|
end
|
|
34
44
|
end
|
|
35
45
|
|
|
46
|
+
sig { params(subschemas: T.untyped).void }
|
|
36
47
|
def compose(*subschemas)
|
|
37
48
|
@schema[:subschemas] ||= []
|
|
38
49
|
@schema[:subschemas] += subschemas
|
|
39
50
|
end
|
|
40
51
|
|
|
41
|
-
|
|
52
|
+
sig { params(name: T.any(Symbol, String), type: T.untyped, constraints: T::Hash[Symbol, T.untyped], block: T.nilable(T.proc.void)).void }
|
|
53
|
+
def property(name, type, constraints = {}, &block)
|
|
54
|
+
validate_property_name(name)
|
|
42
55
|
constraints[:as] ||= @property_naming_strategy.call(name)
|
|
43
|
-
validate_property_name(constraints[:as])
|
|
44
56
|
@schema[:properties] ||= {}
|
|
45
57
|
|
|
46
58
|
if block_given?
|
|
@@ -51,6 +63,7 @@ module EasyTalk
|
|
|
51
63
|
@schema[:properties][name] = { type:, constraints: }
|
|
52
64
|
end
|
|
53
65
|
|
|
66
|
+
sig { params(name: T.any(Symbol, String)).void }
|
|
54
67
|
def validate_property_name(name)
|
|
55
68
|
return if name.to_s.match?(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
|
56
69
|
|
|
@@ -59,11 +72,13 @@ module EasyTalk
|
|
|
59
72
|
raise InvalidPropertyNameError, message
|
|
60
73
|
end
|
|
61
74
|
|
|
75
|
+
sig { returns(T.nilable(T::Boolean)) }
|
|
62
76
|
def optional?
|
|
63
77
|
@schema[:optional]
|
|
64
78
|
end
|
|
65
79
|
|
|
66
80
|
# Helper method for nullable and optional properties
|
|
81
|
+
sig { params(name: Symbol, type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).void }
|
|
67
82
|
def nullable_optional_property(name, type, constraints = {})
|
|
68
83
|
# Ensure type is nilable
|
|
69
84
|
nilable_type = if type.respond_to?(:nilable?) && type.nilable?
|
|
@@ -79,8 +94,39 @@ module EasyTalk
|
|
|
79
94
|
property(name, nilable_type, constraints)
|
|
80
95
|
end
|
|
81
96
|
|
|
97
|
+
sig { params(strategy: T.any(Symbol, T.proc.params(arg0: T.untyped).returns(Symbol))).void }
|
|
82
98
|
def property_naming_strategy(strategy)
|
|
83
99
|
@property_naming_strategy = EasyTalk::NamingStrategies.derive_strategy(strategy)
|
|
84
100
|
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Parses arguments for additional_properties to support multiple syntaxes:
|
|
105
|
+
# - additional_properties true/false (boolean)
|
|
106
|
+
# - additional_properties String (type class)
|
|
107
|
+
# - additional_properties Integer, minimum: 0, maximum: 100 (type + constraints)
|
|
108
|
+
sig { params(args: T::Array[T.untyped]).returns(T.untyped) }
|
|
109
|
+
def parse_additional_properties_args(args)
|
|
110
|
+
return args.first if args.empty?
|
|
111
|
+
|
|
112
|
+
# Single boolean argument
|
|
113
|
+
first_arg = args.first
|
|
114
|
+
return first_arg if args.size == 1 && (first_arg.is_a?(TrueClass) || first_arg.is_a?(FalseClass))
|
|
115
|
+
|
|
116
|
+
# Single type class (String, Integer, custom model, etc.)
|
|
117
|
+
return first_arg if args.size == 1 && first_arg.is_a?(Class)
|
|
118
|
+
|
|
119
|
+
# Type + constraints: additional_properties Integer, minimum: 0, maximum: 100
|
|
120
|
+
# args = [Integer, { minimum: 0, maximum: 100 }] or [Integer, minimum: 0, maximum: 100]
|
|
121
|
+
if args.size >= 1 && args.first.is_a?(Class)
|
|
122
|
+
type = args.first
|
|
123
|
+
# Merge all hash arguments as constraints
|
|
124
|
+
constraints = args[1..].select { |arg| arg.is_a?(Hash) }.reduce({}, :merge)
|
|
125
|
+
return { type:, **constraints }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Fallback: return as-is
|
|
129
|
+
args.first
|
|
130
|
+
end
|
|
85
131
|
end
|
|
86
132
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
module EasyTalk
|
|
4
5
|
# Shared methods for JSON Schema generation.
|
|
@@ -14,10 +15,16 @@ module EasyTalk
|
|
|
14
15
|
#
|
|
15
16
|
module SchemaMethods
|
|
16
17
|
# Returns the reference template for the model.
|
|
18
|
+
# When prefer_external_refs is enabled and the model has a schema ID,
|
|
19
|
+
# returns the external $id URI. Otherwise, returns the local $defs reference.
|
|
17
20
|
#
|
|
18
21
|
# @return [String] The reference template for the model.
|
|
19
22
|
def ref_template
|
|
20
|
-
|
|
23
|
+
config = EasyTalk.configuration
|
|
24
|
+
|
|
25
|
+
# Use external ref when configured and $id available, otherwise fall back to local $defs
|
|
26
|
+
schema_id = resolve_schema_id if config.prefer_external_refs
|
|
27
|
+
schema_id || "#/$defs/#{name}"
|
|
21
28
|
end
|
|
22
29
|
|
|
23
30
|
# Returns the JSON schema for the model.
|
|
@@ -62,19 +69,43 @@ module EasyTalk
|
|
|
62
69
|
end
|
|
63
70
|
end
|
|
64
71
|
|
|
65
|
-
# Resolves the schema ID from per-model setting or global config.
|
|
72
|
+
# Resolves the schema ID from per-model setting, auto-generation, or global config.
|
|
73
|
+
# Precedence order:
|
|
74
|
+
# 1. Per-model explicit schema_id (highest priority)
|
|
75
|
+
# 2. Auto-generated from base_schema_uri (middle priority)
|
|
76
|
+
# 3. Global schema_id (lowest priority)
|
|
66
77
|
#
|
|
67
78
|
# @return [String, nil] The schema ID.
|
|
68
79
|
def resolve_schema_id
|
|
69
80
|
model_id = @schema_definition&.schema&.dig(:schema_id)
|
|
70
81
|
|
|
82
|
+
# Per-model explicit ID takes precedence
|
|
71
83
|
if model_id
|
|
72
84
|
return nil if model_id == :none
|
|
73
85
|
|
|
74
|
-
model_id.to_s
|
|
75
|
-
else
|
|
76
|
-
EasyTalk.configuration.schema_id
|
|
86
|
+
return model_id.to_s
|
|
77
87
|
end
|
|
88
|
+
|
|
89
|
+
# Auto-generate from base_schema_uri if enabled
|
|
90
|
+
config = EasyTalk.configuration
|
|
91
|
+
return generate_schema_id(config.base_schema_uri, name) if config.auto_generate_ids && config.base_schema_uri && name
|
|
92
|
+
|
|
93
|
+
# Fall back to global schema_id
|
|
94
|
+
config.schema_id
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Generates a schema ID from the base URI and model name.
|
|
98
|
+
# Normalizes the base URI and converts the model name to underscore case.
|
|
99
|
+
#
|
|
100
|
+
# @param base_uri [String] The base URI for schema IDs.
|
|
101
|
+
# @param model_name [String] The model class name.
|
|
102
|
+
# @return [String] The generated schema ID.
|
|
103
|
+
def generate_schema_id(base_uri, model_name)
|
|
104
|
+
# Normalize base URI (remove trailing slash)
|
|
105
|
+
base = base_uri.to_s.chomp('/')
|
|
106
|
+
# Convert model name to lowercase with underscores for URI segment
|
|
107
|
+
segment = model_name.to_s.underscore
|
|
108
|
+
"#{base}/#{segment}"
|
|
78
109
|
end
|
|
79
110
|
end
|
|
80
111
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
require 'bigdecimal'
|
|
4
5
|
|
|
@@ -31,6 +32,8 @@ module EasyTalk
|
|
|
31
32
|
}.freeze
|
|
32
33
|
|
|
33
34
|
class << self
|
|
35
|
+
extend T::Sig
|
|
36
|
+
|
|
34
37
|
# Check if type represents a boolean (T::Boolean or TrueClass/FalseClass).
|
|
35
38
|
#
|
|
36
39
|
# @param type [Object] The type to check
|
|
@@ -41,6 +44,7 @@ module EasyTalk
|
|
|
41
44
|
# boolean_type?(TrueClass) # => true
|
|
42
45
|
# boolean_type?(FalseClass) # => true
|
|
43
46
|
# boolean_type?(String) # => false
|
|
47
|
+
sig { params(type: T.untyped).returns(T::Boolean) }
|
|
44
48
|
def boolean_type?(type)
|
|
45
49
|
return false if type.nil?
|
|
46
50
|
return true if [TrueClass, FalseClass].include?(type)
|
|
@@ -58,6 +62,23 @@ module EasyTalk
|
|
|
58
62
|
false
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
# Check if a resolved type class represents a boolean union ([TrueClass, FalseClass]).
|
|
66
|
+
#
|
|
67
|
+
# This is useful when checking resolved type classes rather than raw Sorbet types.
|
|
68
|
+
# The internal representation of T::Boolean resolves to [TrueClass, FalseClass].
|
|
69
|
+
#
|
|
70
|
+
# @param type_class [Object] The resolved type class to check
|
|
71
|
+
# @return [Boolean] true if the type class is a boolean union array
|
|
72
|
+
#
|
|
73
|
+
# @example
|
|
74
|
+
# boolean_union_type?([TrueClass, FalseClass]) # => true
|
|
75
|
+
# boolean_union_type?(TrueClass) # => false
|
|
76
|
+
# boolean_union_type?(String) # => false
|
|
77
|
+
sig { params(type_class: T.untyped).returns(T::Boolean) }
|
|
78
|
+
def boolean_union_type?(type_class)
|
|
79
|
+
type_class.is_a?(Array) && type_class.sort_by(&:name) == [FalseClass, TrueClass].sort_by(&:name)
|
|
80
|
+
end
|
|
81
|
+
|
|
61
82
|
# Check if type is a typed array (T::Array[...]).
|
|
62
83
|
#
|
|
63
84
|
# @param type [Object] The type to check
|
|
@@ -66,12 +87,30 @@ module EasyTalk
|
|
|
66
87
|
# @example
|
|
67
88
|
# typed_array?(T::Array[String]) # => true
|
|
68
89
|
# typed_array?(Array) # => false
|
|
90
|
+
sig { params(type: T.untyped).returns(T::Boolean) }
|
|
69
91
|
def typed_array?(type)
|
|
70
92
|
return false if type.nil?
|
|
71
93
|
|
|
72
94
|
type.is_a?(T::Types::TypedArray)
|
|
73
95
|
end
|
|
74
96
|
|
|
97
|
+
# Check if type is any array type (plain Array, T::Array[...], or T::Tuple[...]).
|
|
98
|
+
#
|
|
99
|
+
# @param type [Object] The type to check
|
|
100
|
+
# @return [Boolean] true if the type is an array type
|
|
101
|
+
#
|
|
102
|
+
# @example
|
|
103
|
+
# array_type?(Array) # => true
|
|
104
|
+
# array_type?(T::Array[String]) # => true
|
|
105
|
+
# array_type?(T::Tuple[String, Integer]) # => true
|
|
106
|
+
# array_type?(String) # => false
|
|
107
|
+
sig { params(type: T.untyped).returns(T::Boolean) }
|
|
108
|
+
def array_type?(type)
|
|
109
|
+
return false if type.nil?
|
|
110
|
+
|
|
111
|
+
type == Array || type.is_a?(T::Types::TypedArray) || type.is_a?(EasyTalk::Types::Tuple)
|
|
112
|
+
end
|
|
113
|
+
|
|
75
114
|
# Check if type is nilable (T.nilable(...)).
|
|
76
115
|
#
|
|
77
116
|
# @param type [Object] The type to check
|
|
@@ -80,6 +119,7 @@ module EasyTalk
|
|
|
80
119
|
# @example
|
|
81
120
|
# nilable_type?(T.nilable(String)) # => true
|
|
82
121
|
# nilable_type?(String) # => false
|
|
122
|
+
sig { params(type: T.untyped).returns(T::Boolean) }
|
|
83
123
|
def nilable_type?(type)
|
|
84
124
|
return false if type.nil?
|
|
85
125
|
|
|
@@ -90,6 +130,7 @@ module EasyTalk
|
|
|
90
130
|
#
|
|
91
131
|
# @param type [Object] The type to check
|
|
92
132
|
# @return [Boolean] true if the type is a primitive
|
|
133
|
+
sig { params(type: T.untyped).returns(T::Boolean) }
|
|
93
134
|
def primitive_type?(type)
|
|
94
135
|
return false if type.nil?
|
|
95
136
|
|
|
@@ -111,6 +152,7 @@ module EasyTalk
|
|
|
111
152
|
# json_schema_type(Float) # => 'number'
|
|
112
153
|
# json_schema_type(BigDecimal) # => 'number'
|
|
113
154
|
# json_schema_type(String) # => 'string'
|
|
155
|
+
sig { params(type: T.untyped).returns(String) }
|
|
114
156
|
def json_schema_type(type)
|
|
115
157
|
return 'object' if type.nil?
|
|
116
158
|
return 'boolean' if boolean_type?(type)
|
|
@@ -133,11 +175,12 @@ module EasyTalk
|
|
|
133
175
|
# get_type_class(String) # => String
|
|
134
176
|
# get_type_class(T::Boolean) # => [TrueClass, FalseClass]
|
|
135
177
|
# get_type_class(T::Array[String]) # => Array
|
|
178
|
+
sig { params(type: T.untyped).returns(T.untyped) }
|
|
136
179
|
def get_type_class(type)
|
|
137
180
|
return nil if type.nil?
|
|
138
181
|
return type if type.is_a?(Class)
|
|
139
182
|
return type.raw_type if type.respond_to?(:raw_type)
|
|
140
|
-
return Array if
|
|
183
|
+
return Array if type.is_a?(T::Types::TypedArray) || type.is_a?(EasyTalk::Types::Tuple)
|
|
141
184
|
return [TrueClass, FalseClass] if boolean_type?(type)
|
|
142
185
|
|
|
143
186
|
if nilable_type?(type)
|
|
@@ -155,6 +198,7 @@ module EasyTalk
|
|
|
155
198
|
#
|
|
156
199
|
# @example
|
|
157
200
|
# extract_inner_type(T.nilable(String)) # => String
|
|
201
|
+
sig { params(type: T.untyped).returns(T.untyped) }
|
|
158
202
|
def extract_inner_type(type)
|
|
159
203
|
return type if type.nil?
|
|
160
204
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyTalk
|
|
4
|
+
module Types
|
|
5
|
+
# Represents a tuple type for arrays with positional type validation.
|
|
6
|
+
#
|
|
7
|
+
# A tuple is an array where each position has a specific type. This class
|
|
8
|
+
# stores the types for each position.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic tuple
|
|
11
|
+
# T::Tuple[String, Integer] # First item must be String, second must be Integer
|
|
12
|
+
#
|
|
13
|
+
# @example With additional_items constraint
|
|
14
|
+
# property :flags, T::Tuple[T::Boolean, T::Boolean], additional_items: false
|
|
15
|
+
#
|
|
16
|
+
class Tuple
|
|
17
|
+
extend T::Sig
|
|
18
|
+
|
|
19
|
+
# @return [Array<Object>] The types for each position in the tuple
|
|
20
|
+
sig { returns(T::Array[T.untyped]) }
|
|
21
|
+
attr_reader :types
|
|
22
|
+
|
|
23
|
+
# Creates a new Tuple instance with the given positional types.
|
|
24
|
+
#
|
|
25
|
+
# @param types [Array] The types for each position in the tuple
|
|
26
|
+
# @raise [ArgumentError] if types is empty or contains nil values
|
|
27
|
+
sig { params(types: T.untyped).void }
|
|
28
|
+
def initialize(*types)
|
|
29
|
+
raise ArgumentError, 'Tuple requires at least one type' if types.empty?
|
|
30
|
+
raise ArgumentError, 'Tuple types cannot be nil' if types.any?(&:nil?)
|
|
31
|
+
|
|
32
|
+
@types = types.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns a string representation of the tuple type.
|
|
36
|
+
#
|
|
37
|
+
# @return [String] A human-readable representation
|
|
38
|
+
sig { returns(String) }
|
|
39
|
+
def to_s
|
|
40
|
+
type_names = @types.map { |t| (t.respond_to?(:name) && t.name) || t.to_s }
|
|
41
|
+
"T::Tuple[#{type_names.join(', ')}]"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the name of this type (used by Property for error messages).
|
|
45
|
+
#
|
|
46
|
+
# @return [String] The type name
|
|
47
|
+
sig { returns(String) }
|
|
48
|
+
def name
|
|
49
|
+
to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Add T::Tuple module for bracket syntax
|
|
56
|
+
module T
|
|
57
|
+
# Provides tuple type syntax: T::Tuple[Type1, Type2, ...]
|
|
58
|
+
#
|
|
59
|
+
# Creates a tuple type that validates array elements by position.
|
|
60
|
+
#
|
|
61
|
+
# @example Basic usage
|
|
62
|
+
# property :coordinates, T::Tuple[Float, Float]
|
|
63
|
+
# property :record, T::Tuple[String, Integer, T::Boolean]
|
|
64
|
+
#
|
|
65
|
+
# @example With additional_items constraint
|
|
66
|
+
# property :flags, T::Tuple[T::Boolean, T::Boolean], additional_items: false
|
|
67
|
+
#
|
|
68
|
+
module Tuple
|
|
69
|
+
# Creates a new Tuple type with the given positional types.
|
|
70
|
+
#
|
|
71
|
+
# @param types [Array] The types for each position
|
|
72
|
+
# @return [EasyTalk::Types::Tuple] A new Tuple instance
|
|
73
|
+
def self.[](*types)
|
|
74
|
+
EasyTalk::Types::Tuple.new(*types)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|