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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/CONTRIBUTING.md +26 -0
- data/LICENSE.txt +22 -0
- data/README.md +227 -0
- data/lib/liminal/adapters/dry_schema_v1.rb +115 -0
- data/lib/liminal/compiler.rb +373 -0
- data/lib/liminal/components.rb +90 -0
- data/lib/liminal/dialects/openapi_30/metadata_validator.rb +123 -0
- data/lib/liminal/dialects/openapi_30.rb +363 -0
- data/lib/liminal/errors.rb +59 -0
- data/lib/liminal/internal/normalizer.rb +247 -0
- data/lib/liminal/internal/schema_merge.rb +95 -0
- data/lib/liminal/path.rb +35 -0
- data/lib/liminal/predicate_registry.rb +235 -0
- data/lib/liminal/schema.rb +95 -0
- data/lib/liminal/version.rb +5 -0
- data/lib/liminal.rb +39 -0
- metadata +175 -0
|
@@ -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
|