liminal 0.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.
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "uri"
5
+ require "liminal/dialects/openapi_30/metadata_validator"
6
+
7
+ module Liminal
8
+ module Dialects
9
+ # OpenAPI 3.0-specific schema behavior and validation.
10
+ class OpenAPI30
11
+ private_constant :MetadataValidator
12
+
13
+ class InvalidFragmentError < StandardError; end
14
+ class UnsupportedPatternError < StandardError; end
15
+
16
+ NAME = :openapi_3_0
17
+ TYPES = %w[array boolean integer number object string].freeze
18
+ KEYWORD_ORDER = %w[
19
+ $ref title type format nullable description properties required items
20
+ enum minimum exclusiveMinimum maximum exclusiveMaximum multipleOf
21
+ minLength maxLength pattern minItems maxItems uniqueItems minProperties
22
+ maxProperties additionalProperties allOf oneOf anyOf not default example
23
+ readOnly writeOnly deprecated discriminator xml externalDocs
24
+ ].freeze
25
+ ALLOWED_KEYWORDS = KEYWORD_ORDER.to_set.freeze
26
+ SCHEMA_ARRAY_KEYWORDS = %w[allOf oneOf anyOf].freeze
27
+ NUMERIC_KEYWORDS = %w[maximum minimum].freeze
28
+ SIZE_KEYWORDS = %w[maxLength minLength maxItems minItems maxProperties minProperties].freeze
29
+ BOOLEAN_KEYWORDS = %w[exclusiveMaximum exclusiveMinimum uniqueItems nullable readOnly writeOnly deprecated].freeze
30
+ STRING_KEYWORDS = %w[$ref title description format pattern].freeze
31
+ RUBY_ONLY_PATTERN_TOKENS = [
32
+ /\\[CEGHhKMNOPRXYZegky]/,
33
+ /\\[ghk]<|\\k'/,
34
+ /\\[pP]\{/,
35
+ /\\u\{/,
36
+ /\[\[:/,
37
+ /\[[^\]]*&&/,
38
+ /\(\?>|\(\?#|\(\?~|\(\?\(|\(\?<=[^)]|\(\?<!/,
39
+ /\(\?<[^=!]|\(\?'/,
40
+ /\(\?[imx-]+[:)]/,
41
+ /(?:\*|\+|\?|\{[^}]+\})\+/
42
+ ].freeze
43
+
44
+ attr_reader :name
45
+
46
+ def initialize
47
+ @name = NAME
48
+ end
49
+
50
+ def nullable(fragment)
51
+ if fragment.key?("type")
52
+ fragment.merge("nullable" => true)
53
+ elsif fragment.key?("anyOf") && fragment.keys == ["anyOf"]
54
+ {"anyOf" => fragment.fetch("anyOf").map { |alternative| nullable(alternative) }}
55
+ else
56
+ raise InvalidFragmentError,
57
+ "nullable schemas require an explicit type in the same Schema Object"
58
+ end
59
+ end
60
+
61
+ def pattern(regexp)
62
+ raise UnsupportedPatternError, "expected a Ruby Regexp" unless regexp.is_a?(Regexp)
63
+ raise UnsupportedPatternError, "Ruby regular-expression options are not supported" unless regexp.options.zero?
64
+
65
+ source = regexp.source
66
+ if ruby_line_anchor?(source)
67
+ raise UnsupportedPatternError,
68
+ "Ruby ^ and $ line anchors are not safely ECMA-262-compatible; use \\A and \\z"
69
+ end
70
+ if RUBY_ONLY_PATTERN_TOKENS.any? { |token| token.match?(source) }
71
+ raise UnsupportedPatternError, "the Ruby regular expression is not safely ECMA-262-compatible"
72
+ end
73
+
74
+ translate_anchors(source)
75
+ end
76
+
77
+ # Validate a recursively string-keyed Schema Object fragment.
78
+ def validate!(schema)
79
+ validate_schema!(schema)
80
+ schema
81
+ end
82
+
83
+ # Produce stable keyword ordering while preserving deterministic property
84
+ # order from the dry-schema AST.
85
+ def order(schema)
86
+ order_schema(schema)
87
+ end
88
+
89
+ private
90
+
91
+ def ruby_line_anchor?(source)
92
+ escaped = false
93
+ character_class = false
94
+
95
+ source.each_char do |character|
96
+ if escaped
97
+ escaped = false
98
+ elsif character == "\\"
99
+ escaped = true
100
+ elsif character == "["
101
+ character_class = true
102
+ elsif character == "]"
103
+ character_class = false
104
+ elsif !character_class && %w[^ $].include?(character)
105
+ return true
106
+ end
107
+ end
108
+
109
+ false
110
+ end
111
+
112
+ def translate_anchors(source)
113
+ translated = +""
114
+ index = 0
115
+ while index < source.length
116
+ unless source[index] == "\\"
117
+ translated << source[index]
118
+ index += 1
119
+ next
120
+ end
121
+
122
+ slash_start = index
123
+ index += 1 while index < source.length && source[index] == "\\"
124
+ slash_count = index - slash_start
125
+ anchor = source[index]
126
+ if slash_count.odd? && %w[A z].include?(anchor)
127
+ translated << ("\\" * (slash_count - 1)) << (anchor == "A" ? "^" : "$")
128
+ index += 1
129
+ else
130
+ translated << source[slash_start, slash_count]
131
+ end
132
+ end
133
+ translated
134
+ end
135
+
136
+ def validate_schema!(schema)
137
+ raise InvalidFragmentError, "schema fragments must be hashes" unless schema.is_a?(Hash)
138
+
139
+ unknown = schema.keys.reject { |key| ALLOWED_KEYWORDS.include?(key) || key.start_with?("x-") }
140
+ raise InvalidFragmentError, "unsupported OpenAPI 3.0 keyword #{unknown.first.inspect}" unless unknown.empty?
141
+
142
+ if schema.key?("$ref") && schema.length > 1
143
+ raise InvalidFragmentError, "OpenAPI 3.0 does not permit siblings beside $ref"
144
+ end
145
+
146
+ validate_type!(schema)
147
+ validate_keyword_values!(schema)
148
+ validate_boundaries!(schema)
149
+ validate_nullable!(schema)
150
+ validate_children!(schema)
151
+ end
152
+
153
+ def validate_type!(schema)
154
+ return unless schema.key?("type")
155
+
156
+ type = schema.fetch("type")
157
+ return if type.is_a?(String) && TYPES.include?(type)
158
+
159
+ raise InvalidFragmentError, "type must be one OpenAPI 3.0 type string (never null or an array)"
160
+ end
161
+
162
+ def validate_boundaries!(schema)
163
+ %w[exclusiveMinimum exclusiveMaximum].each do |keyword|
164
+ next unless schema.key?(keyword)
165
+ next if [true, false].include?(schema.fetch(keyword))
166
+
167
+ raise InvalidFragmentError, "#{keyword} must be boolean in OpenAPI 3.0"
168
+ end
169
+ end
170
+
171
+ def validate_keyword_values!(schema)
172
+ STRING_KEYWORDS.each do |keyword|
173
+ next unless schema.key?(keyword)
174
+ next if schema.fetch(keyword).is_a?(String)
175
+
176
+ raise InvalidFragmentError, "#{keyword} must be a string"
177
+ end
178
+
179
+ validate_ref!(schema)
180
+ NUMERIC_KEYWORDS.each { |keyword| validate_numeric_keyword!(schema, keyword) }
181
+ validate_multiple_of!(schema)
182
+ SIZE_KEYWORDS.each { |keyword| validate_size_keyword!(schema, keyword) }
183
+ BOOLEAN_KEYWORDS.each { |keyword| validate_boolean_keyword!(schema, keyword) }
184
+ validate_required!(schema)
185
+ validate_enum!(schema)
186
+ validate_json_value!(schema, "default")
187
+ validate_json_value!(schema, "example")
188
+ MetadataValidator.validate!(schema) { |value| json_value?(value) }
189
+ schema.each do |keyword, value|
190
+ next unless keyword.start_with?("x-")
191
+ next if json_value?(value)
192
+
193
+ raise InvalidFragmentError, "#{keyword} must be an OpenAPI-compatible value"
194
+ end
195
+
196
+ return unless schema["readOnly"] == true && schema["writeOnly"] == true
197
+
198
+ raise InvalidFragmentError, "readOnly and writeOnly cannot both be true"
199
+ end
200
+
201
+ def validate_numeric_keyword!(schema, keyword)
202
+ return unless schema.key?(keyword)
203
+
204
+ value = schema.fetch(keyword)
205
+ return if finite_number?(value)
206
+
207
+ raise InvalidFragmentError, "#{keyword} must be a finite number"
208
+ end
209
+
210
+ def validate_ref!(schema)
211
+ return unless schema.key?("$ref")
212
+
213
+ URI.parse(schema.fetch("$ref"))
214
+ rescue URI::InvalidURIError
215
+ raise InvalidFragmentError, "$ref must be a valid URI reference"
216
+ end
217
+
218
+ def validate_multiple_of!(schema)
219
+ return unless schema.key?("multipleOf")
220
+
221
+ value = schema.fetch("multipleOf")
222
+ return if finite_number?(value) && value.positive?
223
+
224
+ raise InvalidFragmentError, "multipleOf must be a positive finite number"
225
+ end
226
+
227
+ def validate_size_keyword!(schema, keyword)
228
+ return unless schema.key?(keyword)
229
+
230
+ value = schema.fetch(keyword)
231
+ return if value.is_a?(Integer) && !value.negative?
232
+
233
+ raise InvalidFragmentError, "#{keyword} must be a non-negative integer"
234
+ end
235
+
236
+ def validate_boolean_keyword!(schema, keyword)
237
+ return unless schema.key?(keyword)
238
+ return if [true, false].include?(schema.fetch(keyword))
239
+
240
+ raise InvalidFragmentError, "#{keyword} must be boolean"
241
+ end
242
+
243
+ def validate_required!(schema)
244
+ return unless schema.key?("required")
245
+
246
+ required = schema.fetch("required")
247
+ return if required.is_a?(Array) && required.all?(String) && required.uniq == required
248
+
249
+ raise InvalidFragmentError, "required must contain unique property-name strings"
250
+ end
251
+
252
+ def validate_enum!(schema)
253
+ return unless schema.key?("enum")
254
+
255
+ values = schema.fetch("enum")
256
+ valid = values.is_a?(Array) && !values.empty? && values.uniq == values
257
+ valid &&= values.all? { |value| json_value?(value) }
258
+ return if valid
259
+
260
+ raise InvalidFragmentError, "enum must contain unique OpenAPI-compatible values"
261
+ end
262
+
263
+ def validate_json_value!(schema, keyword)
264
+ return unless schema.key?(keyword)
265
+ return if json_value?(schema.fetch(keyword))
266
+
267
+ raise InvalidFragmentError, "#{keyword} must be an OpenAPI-compatible value"
268
+ end
269
+
270
+ def json_value?(value)
271
+ case value
272
+ when nil, true, false, String, Integer
273
+ true
274
+ when Float
275
+ value.finite?
276
+ when Array
277
+ value.all? { |item| json_value?(item) }
278
+ when Hash
279
+ value.all? { |key, item| key.is_a?(String) && json_value?(item) }
280
+ else
281
+ false
282
+ end
283
+ end
284
+
285
+ def finite_number?(value)
286
+ value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)
287
+ end
288
+
289
+ def validate_nullable!(schema)
290
+ return unless schema["nullable"] == true
291
+ return if schema["type"].is_a?(String)
292
+
293
+ raise InvalidFragmentError,
294
+ "nullable requires an explicit type in the same OpenAPI 3.0 Schema Object"
295
+ end
296
+
297
+ def validate_children!(schema)
298
+ if schema.key?("properties")
299
+ properties = schema.fetch("properties")
300
+ raise InvalidFragmentError, "properties must be a hash" unless properties.is_a?(Hash)
301
+
302
+ properties.each_value { |child| validate_schema!(child) }
303
+ end
304
+
305
+ if schema["type"] == "array" && !schema.key?("items")
306
+ raise InvalidFragmentError, "array schemas must define items"
307
+ end
308
+
309
+ validate_schema!(schema.fetch("items")) if schema.key?("items")
310
+
311
+ SCHEMA_ARRAY_KEYWORDS.each do |keyword|
312
+ next unless schema.key?(keyword)
313
+
314
+ alternatives = schema.fetch(keyword)
315
+ unless alternatives.is_a?(Array) && !alternatives.empty?
316
+ raise InvalidFragmentError, "#{keyword} must be a non-empty array of schemas"
317
+ end
318
+
319
+ alternatives.each { |child| validate_schema!(child) }
320
+ end
321
+
322
+ validate_schema!(schema.fetch("not")) if schema.key?("not")
323
+
324
+ validate_additional_properties!(schema)
325
+ end
326
+
327
+ def validate_additional_properties!(schema)
328
+ return unless schema.key?("additionalProperties")
329
+
330
+ additional = schema.fetch("additionalProperties")
331
+ valid = additional == true || additional == false || additional.is_a?(Hash)
332
+ raise InvalidFragmentError, "additionalProperties must be boolean or a schema" unless valid
333
+
334
+ validate_schema!(additional) if additional.is_a?(Hash)
335
+ end
336
+
337
+ def order_schema(schema)
338
+ ordered_keys = schema.keys.sort_by do |key|
339
+ index = KEYWORD_ORDER.index(key)
340
+ [index || KEYWORD_ORDER.length, index ? "" : key]
341
+ end
342
+
343
+ ordered_keys.each_with_object({}) do |key, ordered|
344
+ value = schema.fetch(key)
345
+ ordered[key] = case key
346
+ when "properties"
347
+ value.each_with_object({}) do |(property, child), properties|
348
+ properties[property] = order_schema(child)
349
+ end
350
+ when "items", "not"
351
+ order_schema(value)
352
+ when *SCHEMA_ARRAY_KEYWORDS
353
+ value.map { |child| order_schema(child) }
354
+ when "additionalProperties"
355
+ value.is_a?(Hash) ? order_schema(value) : value
356
+ else
357
+ value
358
+ end
359
+ end
360
+ end
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ # Base class for all domain errors raised by the gem.
5
+ class Error < StandardError; end
6
+
7
+ class InvalidSchemaSourceError < Error; end
8
+ class UnsupportedDialectError < Error; end
9
+ class InvalidOverrideError < Error; end
10
+ class UnknownOverridePathError < Error; end
11
+ class UnknownExclusionPathError < Error; end
12
+ class PredicateAlreadyRegisteredError < Error; end
13
+ class ComponentCollisionError < Error; end
14
+ class InvalidComponentNameError < Error; end
15
+ class InvalidSchemaError < Error; end
16
+ class InvalidSchemaCompositionError < InvalidSchemaError; end
17
+
18
+ # Raised when a dry-schema AST node cannot be represented.
19
+ class UnsupportedNodeError < Error
20
+ attr_reader :source_name, :path, :operation
21
+
22
+ def initialize(source_name:, path:, operation:)
23
+ @source_name = source_name
24
+ @path = path.map { |segment| segment.to_s.dup.freeze }.freeze
25
+ @operation = operation
26
+ super("Cannot compile #{source_name} at #{Path.display(path)}: " \
27
+ "unsupported AST node #{operation}.")
28
+ end
29
+ end
30
+
31
+ # Raised when a dry predicate cannot be represented.
32
+ class UnsupportedPredicateError < Error
33
+ attr_reader :source_name, :path, :predicate
34
+
35
+ def initialize(source_name:, path:, predicate:, detail: nil)
36
+ @source_name = source_name
37
+ @path = path.map { |segment| segment.to_s.dup.freeze }.freeze
38
+ @predicate = predicate
39
+ detail_text = detail ? " (#{detail})" : ""
40
+ super(<<~MESSAGE.chomp)
41
+ Cannot compile #{source_name} at #{Path.display(path)}: unsupported predicate #{predicate}#{detail_text}.
42
+ Add an authoritative override or register a predicate handler.
43
+ MESSAGE
44
+ end
45
+ end
46
+
47
+ # Raised when a predicate handler returns a non-schema value.
48
+ class InvalidPredicateResultError < Error
49
+ attr_reader :source_name, :path, :predicate
50
+
51
+ def initialize(source_name:, path:, predicate:, detail:)
52
+ @source_name = source_name
53
+ @path = path.map { |segment| segment.to_s.dup.freeze }.freeze
54
+ @predicate = predicate
55
+ super("Invalid result for predicate #{predicate} while compiling " \
56
+ "#{source_name} at #{Path.display(path)}: #{detail}.")
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Liminal
7
+ module Internal
8
+ # Resolves private predicate constraints after type fragments have merged.
9
+ class Normalizer
10
+ CONSTRAINTS_KEY = "__liminal_constraints__"
11
+ UNSET = Object.new.freeze
12
+ private_constant :UNSET
13
+
14
+ class << self
15
+ def constraint(name, argument = UNSET)
16
+ entry = argument.equal?(UNSET) ? [name] : [name, argument]
17
+ {CONSTRAINTS_KEY => [entry.freeze]}
18
+ end
19
+
20
+ def normalize_keys(value, path = [])
21
+ case value
22
+ when Hash
23
+ original_keys = {}
24
+ value.each_with_object({}) do |(key, item), normalized|
25
+ unless key.is_a?(String) || key.is_a?(Symbol)
26
+ raise InvalidSchemaError,
27
+ "OpenAPI object key #{key.inspect} at #{Path.display(path)} must be a String or Symbol."
28
+ end
29
+
30
+ normalized_key = key.to_s.dup
31
+ if original_keys.key?(normalized_key)
32
+ raise InvalidSchemaError,
33
+ "OpenAPI object keys #{original_keys.fetch(normalized_key).inspect} and #{key.inspect} " \
34
+ "both normalize to #{normalized_key.inspect} at #{Path.display(path)}."
35
+ end
36
+
37
+ original_keys[normalized_key] = key
38
+ normalized[normalized_key] = normalize_keys(item, path + [normalized_key])
39
+ end
40
+ when Array
41
+ value.map.with_index { |item, index| normalize_keys(item, path + [index]) }
42
+ when String
43
+ value.dup
44
+ else
45
+ value
46
+ end
47
+ end
48
+ end
49
+
50
+ def initialize(source_name:)
51
+ @source_name = source_name
52
+ end
53
+
54
+ def call(fragment, path: [])
55
+ normalize_schema(self.class.normalize_keys(fragment), path)
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_schema(schema, path)
61
+ constraints = schema.delete(CONSTRAINTS_KEY) || []
62
+ schema = apply_constraints(schema, constraints, path) unless constraints.empty?
63
+
64
+ schema["items"] = {} if (schema["type"] == "array") && !schema.key?("items")
65
+ schema.delete("required") if schema["required"] == []
66
+
67
+ normalize_children!(schema, path)
68
+ normalize_values(schema, path)
69
+ end
70
+
71
+ def normalize_children!(schema, path)
72
+ if schema["properties"].is_a?(Hash)
73
+ schema["properties"] = schema.fetch("properties").each_with_object({}) do |(name, child), properties|
74
+ properties[name.to_s] = normalize_schema(child, path + [name.to_s])
75
+ end
76
+ end
77
+
78
+ schema["items"] = normalize_schema(schema.fetch("items"), path) if schema["items"].is_a?(Hash)
79
+
80
+ %w[allOf oneOf anyOf].each do |keyword|
81
+ next unless schema[keyword].is_a?(Array)
82
+
83
+ schema[keyword] = schema.fetch(keyword).map { |child| normalize_schema(child, path) }
84
+ end
85
+
86
+ schema["not"] = normalize_schema(schema.fetch("not"), path) if schema["not"].is_a?(Hash)
87
+ return unless schema["additionalProperties"].is_a?(Hash)
88
+
89
+ schema["additionalProperties"] = normalize_schema(schema.fetch("additionalProperties"), path)
90
+ end
91
+
92
+ def apply_constraints(schema, constraints, path)
93
+ types = resolved_types(schema)
94
+ if types.length > 1 && schema["anyOf"].is_a?(Array)
95
+ marker = {CONSTRAINTS_KEY => constraints}
96
+ schema["anyOf"] = schema.fetch("anyOf").map do |alternative|
97
+ normalize_schema(SchemaMerge.combine(alternative, marker), path)
98
+ end
99
+ return schema
100
+ end
101
+
102
+ type = types.first
103
+ unsupported!(constraints.first.first, path, "the field type could not be resolved") unless type
104
+
105
+ apply_size_constraints(schema, constraints, type, path)
106
+ end
107
+
108
+ def apply_size_constraints(schema, constraints, type, path)
109
+ discrete_sizes = nil
110
+
111
+ constraints.each do |name, argument|
112
+ case name
113
+ when :filled?
114
+ apply_filled!(schema, type, path)
115
+ when :min_size?
116
+ apply_minimum_size!(schema, type, validate_size!(name, argument, path), path)
117
+ when :max_size?
118
+ apply_maximum_size!(schema, type, validate_size!(name, argument, path), path)
119
+ when :size?
120
+ discrete_sizes = apply_size!(schema, type, argument, path, discrete_sizes)
121
+ end
122
+ end
123
+
124
+ return apply_discrete_sizes(schema, type, discrete_sizes, path) if discrete_sizes
125
+
126
+ schema
127
+ end
128
+
129
+ def apply_filled!(schema, type, path)
130
+ return if %w[boolean integer number].include?(type)
131
+
132
+ minimum, = size_keywords(type, :filled?, path)
133
+ schema[minimum] = [schema[minimum], 1].compact.max
134
+ end
135
+
136
+ def apply_minimum_size!(schema, type, value, path)
137
+ minimum, = size_keywords(type, :min_size?, path)
138
+ schema[minimum] = [schema[minimum], value].compact.max
139
+ end
140
+
141
+ def apply_maximum_size!(schema, type, value, path)
142
+ _, maximum = size_keywords(type, :max_size?, path)
143
+ schema[maximum] = [schema[maximum], value].compact.min
144
+ end
145
+
146
+ def apply_size!(schema, type, argument, path, prior_discrete)
147
+ case argument
148
+ when Integer
149
+ value = validate_size!(:size?, argument, path)
150
+ set_exact_size!(schema, type, value, path)
151
+ prior_discrete
152
+ when Range
153
+ apply_size_range!(schema, type, argument, path)
154
+ prior_discrete
155
+ when Array
156
+ values = argument.map { |value| validate_size!(:size?, value, path) }.uniq.sort
157
+ prior_discrete ? prior_discrete & values : values
158
+ else
159
+ unsupported!(:size?, path, "unsupported size argument #{argument.inspect}")
160
+ end
161
+ end
162
+
163
+ def apply_size_range!(schema, type, range, path)
164
+ first = validate_size!(:size?, range.begin, path)
165
+ last = validate_size!(:size?, range.end, path)
166
+ last -= 1 if range.exclude_end?
167
+ unsupported!(:size?, path, "the exclusive range has no non-negative endpoint") if last.negative?
168
+
169
+ apply_minimum_size!(schema, type, first, path)
170
+ apply_maximum_size!(schema, type, last, path)
171
+ end
172
+
173
+ def set_exact_size!(schema, type, value, path)
174
+ minimum, maximum = size_keywords(type, :size?, path)
175
+ schema[minimum] = value
176
+ schema[maximum] = value
177
+ end
178
+
179
+ def apply_discrete_sizes(schema, type, values, path)
180
+ minimum, maximum = size_keywords(type, :size?, path)
181
+ values = values.select do |value|
182
+ (!schema[minimum] || value >= schema[minimum]) && (!schema[maximum] || value <= schema[maximum])
183
+ end
184
+
185
+ discrete = if values.empty?
186
+ {"not" => {}}
187
+ else
188
+ {"anyOf" => values.map { |value| {minimum => value, maximum => value} }}
189
+ end
190
+ SchemaMerge.combine(schema, discrete)
191
+ end
192
+
193
+ def validate_size!(predicate, value, path)
194
+ return value if value.is_a?(Integer) && !value.negative?
195
+
196
+ unsupported!(predicate, path, "size values must be non-negative integers")
197
+ end
198
+
199
+ def size_keywords(type, predicate, path)
200
+ case type
201
+ when "string" then %w[minLength maxLength]
202
+ when "array" then %w[minItems maxItems]
203
+ when "object" then %w[minProperties maxProperties]
204
+ else unsupported!(predicate, path, "size constraints do not apply to #{type}")
205
+ end
206
+ end
207
+
208
+ def resolved_types(schema)
209
+ return [schema.fetch("type")] if schema.key?("type")
210
+
211
+ %w[allOf anyOf oneOf].each do |keyword|
212
+ next unless schema[keyword].is_a?(Array)
213
+
214
+ types = schema.fetch(keyword).flat_map { |child| resolved_types(child) }.uniq
215
+ return types unless types.empty?
216
+ end
217
+ []
218
+ end
219
+
220
+ def normalize_values(value, path)
221
+ case value
222
+ when Hash
223
+ value.each_with_object({}) do |(key, item), normalized|
224
+ normalized[key.to_s] = normalize_values(item, path + [key.to_s])
225
+ end
226
+ when Array
227
+ value.map.with_index { |item, index| normalize_values(item, path + [index]) }
228
+ when Symbol
229
+ value.to_s
230
+ when Date, Time
231
+ value.iso8601
232
+ else
233
+ value
234
+ end
235
+ end
236
+
237
+ def unsupported!(predicate, path, detail)
238
+ raise UnsupportedPredicateError.new(
239
+ source_name: @source_name,
240
+ path: path,
241
+ predicate: predicate,
242
+ detail: detail
243
+ )
244
+ end
245
+ end
246
+ end
247
+ end