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,373 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Liminal
|
|
6
|
+
# Coordinates AST adaptation, predicate compilation, overrides, and dialect
|
|
7
|
+
# validation. Raw dry-schema AST layout is intentionally absent here.
|
|
8
|
+
class Compiler
|
|
9
|
+
NULL_FRAGMENT = Object.new.freeze
|
|
10
|
+
AUTHORITATIVE_KEYS = %w[type $ref oneOf anyOf allOf].freeze
|
|
11
|
+
OVERRIDABLE_ERRORS = [UnsupportedNodeError, UnsupportedPredicateError].freeze
|
|
12
|
+
private_constant :NULL_FRAGMENT, :AUTHORITATIVE_KEYS, :OVERRIDABLE_ERRORS
|
|
13
|
+
|
|
14
|
+
def initialize(schema, dialect:, source_name:, exclude:, overrides:, predicates:)
|
|
15
|
+
@schema = schema
|
|
16
|
+
@source_name = source_name || infer_source_name(schema)
|
|
17
|
+
@dialect = build_dialect(dialect)
|
|
18
|
+
@excluded_paths = normalize_exclusions(exclude)
|
|
19
|
+
@overrides = normalize_overrides(overrides)
|
|
20
|
+
@predicates = predicates
|
|
21
|
+
validate_predicates!
|
|
22
|
+
validate_path_configuration!
|
|
23
|
+
@adapter = Adapters::DrySchemaV1.new
|
|
24
|
+
@normalizer = Internal::Normalizer.new(source_name: @source_name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
validate_source!
|
|
29
|
+
root = @adapter.call(@schema.to_ast)
|
|
30
|
+
@known_paths = collect_paths(root)
|
|
31
|
+
|
|
32
|
+
compiled = compile_node(root, [])
|
|
33
|
+
check_unknown_paths!
|
|
34
|
+
normalized = @normalizer.call(compiled)
|
|
35
|
+
@dialect.validate!(normalized)
|
|
36
|
+
@dialect.order(normalized)
|
|
37
|
+
rescue Dialects::OpenAPI30::InvalidFragmentError => e
|
|
38
|
+
raise Error, "Compiler produced an invalid OpenAPI 3.0 schema for #{@source_name}: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_source!
|
|
44
|
+
return if @schema.respond_to?(:to_ast)
|
|
45
|
+
|
|
46
|
+
raise InvalidSchemaSourceError,
|
|
47
|
+
"Schema source #{@source_name} must respond to to_ast."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def infer_source_name(schema)
|
|
51
|
+
name = schema.class.name if schema
|
|
52
|
+
name && !name.empty? ? name : "anonymous schema"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_dialect(name)
|
|
56
|
+
return Dialects::OpenAPI30.new.freeze if name == :openapi_3_0
|
|
57
|
+
|
|
58
|
+
raise UnsupportedDialectError,
|
|
59
|
+
"Unsupported dialect #{name.inspect}; only :openapi_3_0 is available."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_predicates!
|
|
63
|
+
return if @predicates.respond_to?(:entry)
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "predicates must respond to entry."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_path_configuration!
|
|
69
|
+
conflict = @excluded_paths.to_a.product(@overrides.keys).find do |excluded, overridden|
|
|
70
|
+
path_prefix?(excluded, overridden)
|
|
71
|
+
end
|
|
72
|
+
return unless conflict
|
|
73
|
+
|
|
74
|
+
excluded, overridden = conflict
|
|
75
|
+
raise InvalidOverrideError,
|
|
76
|
+
"Override at #{Path.display(overridden)} conflicts with exclusion at #{Path.display(excluded)}."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def path_prefix?(prefix, path)
|
|
80
|
+
path.first(prefix.length) == prefix
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_exclusions(exclusions)
|
|
84
|
+
Array(exclusions).each_with_object(Set.new) do |path, paths|
|
|
85
|
+
paths << Path.normalize(path)
|
|
86
|
+
rescue ArgumentError => e
|
|
87
|
+
raise UnknownExclusionPathError, "Invalid exclusion path #{path.inspect}: #{e.message}."
|
|
88
|
+
end.freeze
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def normalize_overrides(overrides)
|
|
92
|
+
raise InvalidOverrideError, "overrides must be a hash keyed by property paths." unless overrides.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
overrides.each_with_object({}) do |(path, fragment), normalized|
|
|
95
|
+
canonical = begin
|
|
96
|
+
Path.normalize(path)
|
|
97
|
+
rescue ArgumentError => e
|
|
98
|
+
raise InvalidOverrideError, "Invalid override path #{path.inspect}: #{e.message}."
|
|
99
|
+
end
|
|
100
|
+
unless fragment.is_a?(Hash)
|
|
101
|
+
raise InvalidOverrideError,
|
|
102
|
+
"Override at #{Path.display(canonical)} must be an OpenAPI fragment hash."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
value = begin
|
|
106
|
+
Internal::Normalizer.normalize_keys(fragment)
|
|
107
|
+
rescue InvalidSchemaError => e
|
|
108
|
+
raise InvalidOverrideError,
|
|
109
|
+
"Invalid override at #{Path.display(canonical)}: #{e.message}."
|
|
110
|
+
end
|
|
111
|
+
if normalized.key?(canonical) && normalized.fetch(canonical) != value
|
|
112
|
+
raise InvalidOverrideError, "Conflicting overrides normalize to #{canonical.inspect}."
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
normalized[canonical] = value
|
|
116
|
+
end.freeze
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def collect_paths(root)
|
|
120
|
+
paths = Set.new
|
|
121
|
+
walk_paths(root, [], paths)
|
|
122
|
+
paths.freeze
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def walk_paths(node, path, paths)
|
|
126
|
+
case node
|
|
127
|
+
when Adapters::DrySchemaV1::FieldNode
|
|
128
|
+
field_path = (path + [node.name]).freeze
|
|
129
|
+
paths << field_path
|
|
130
|
+
walk_paths(node.rule, field_path, paths)
|
|
131
|
+
when Adapters::DrySchemaV1::SetNode, Adapters::DrySchemaV1::AndNode,
|
|
132
|
+
Adapters::DrySchemaV1::OrNode
|
|
133
|
+
node.children.each { |child| walk_paths(child, path, paths) }
|
|
134
|
+
when Adapters::DrySchemaV1::NullableNode, Adapters::DrySchemaV1::NotNode,
|
|
135
|
+
Adapters::DrySchemaV1::EachNode
|
|
136
|
+
walk_paths(node.rule, path, paths)
|
|
137
|
+
when Adapters::DrySchemaV1::ImplicationNode
|
|
138
|
+
walk_paths(node.left, path, paths)
|
|
139
|
+
walk_paths(node.right, path, paths)
|
|
140
|
+
when Adapters::DrySchemaV1::KeyNode
|
|
141
|
+
walk_paths(node.rule, path + [node.name], paths)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def compile_node(node, path)
|
|
146
|
+
case node
|
|
147
|
+
when Adapters::DrySchemaV1::SetNode then compile_set(node, path)
|
|
148
|
+
when Adapters::DrySchemaV1::FieldNode then compile_field(node, path)
|
|
149
|
+
when Adapters::DrySchemaV1::PredicateNode then compile_predicate(node, path)
|
|
150
|
+
when Adapters::DrySchemaV1::AndNode then compile_and(node, path)
|
|
151
|
+
when Adapters::DrySchemaV1::OrNode then compile_or(node, path)
|
|
152
|
+
when Adapters::DrySchemaV1::NullableNode then compile_nullable(node, path)
|
|
153
|
+
when Adapters::DrySchemaV1::EachNode then compile_each(node, path)
|
|
154
|
+
when Adapters::DrySchemaV1::ImplicationNode then unsupported_node!(:implication, path)
|
|
155
|
+
when Adapters::DrySchemaV1::NotNode then unsupported_node!(:not, path)
|
|
156
|
+
when Adapters::DrySchemaV1::KeyNode then unsupported_node!(:key, path)
|
|
157
|
+
when Adapters::DrySchemaV1::UnknownNode then unsupported_node!(node.operation, path)
|
|
158
|
+
else unsupported_node!(:invalid_ast, path)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def compile_set(node, path)
|
|
163
|
+
properties = {}
|
|
164
|
+
required = []
|
|
165
|
+
additional_fragments = []
|
|
166
|
+
|
|
167
|
+
node.children.each do |child|
|
|
168
|
+
unless child.is_a?(Adapters::DrySchemaV1::FieldNode)
|
|
169
|
+
additional_fragments << compile_node(child, path)
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
field_path = path + [child.name]
|
|
174
|
+
next if @excluded_paths.include?(field_path)
|
|
175
|
+
|
|
176
|
+
fragment = compile_field(child, path)
|
|
177
|
+
properties[child.name] = if properties.key?(child.name)
|
|
178
|
+
Internal::SchemaMerge.combine(properties.fetch(child.name), fragment)
|
|
179
|
+
else
|
|
180
|
+
fragment
|
|
181
|
+
end
|
|
182
|
+
required << child.name if child.required && !required.include?(child.name)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
result = {"type" => "object", "properties" => properties}
|
|
186
|
+
result["required"] = required unless required.empty?
|
|
187
|
+
additional_fragments.reduce(result) do |combined, fragment|
|
|
188
|
+
Internal::SchemaMerge.combine(combined, fragment)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def compile_field(node, parent_path)
|
|
193
|
+
path = parent_path + [node.name]
|
|
194
|
+
override = @overrides[path]
|
|
195
|
+
|
|
196
|
+
fragment = begin
|
|
197
|
+
compile_node(node.rule, path)
|
|
198
|
+
rescue *OVERRIDABLE_ERRORS => e
|
|
199
|
+
raise unless override && authoritative?(override) && e.path == path
|
|
200
|
+
|
|
201
|
+
validate_override!(override, path)
|
|
202
|
+
override
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
unsupported_predicate!(:nil?, path, "a null-only schema is not valid in OpenAPI 3.0") if null_fragment?(fragment)
|
|
206
|
+
|
|
207
|
+
if override && fragment != override
|
|
208
|
+
validate_override!(override, path)
|
|
209
|
+
fragment = Internal::SchemaMerge.override(fragment, override)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
normalize_and_validate_field(fragment, path, overridden: !override.nil?)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def normalize_and_validate_field(fragment, path, overridden:)
|
|
216
|
+
normalized = @normalizer.call(fragment, path: path)
|
|
217
|
+
@dialect.validate!(normalized)
|
|
218
|
+
normalized
|
|
219
|
+
rescue Dialects::OpenAPI30::InvalidFragmentError => e
|
|
220
|
+
if overridden
|
|
221
|
+
raise InvalidOverrideError,
|
|
222
|
+
"Invalid override at #{Path.display(path)}: #{e.message}."
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
raise Error,
|
|
226
|
+
"Generated invalid OpenAPI schema at #{Path.display(path)}: #{e.message}."
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_override!(fragment, path)
|
|
230
|
+
@dialect.validate!(fragment)
|
|
231
|
+
rescue Dialects::OpenAPI30::InvalidFragmentError => e
|
|
232
|
+
raise InvalidOverrideError,
|
|
233
|
+
"Invalid override at #{Path.display(path)}: #{e.message}."
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def authoritative?(fragment)
|
|
237
|
+
fragment.keys.intersect?(AUTHORITATIVE_KEYS)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def compile_predicate(node, path)
|
|
241
|
+
return compile_nil(node, path) if node.name == :nil?
|
|
242
|
+
return unsupported_predicate!(node.name, path) if node.name == :key?
|
|
243
|
+
|
|
244
|
+
entry = @predicates.entry(node.name)
|
|
245
|
+
return unsupported_predicate!(node.name, path) unless entry
|
|
246
|
+
|
|
247
|
+
context = PredicateContext.new(path: path, dialect: @dialect)
|
|
248
|
+
result = begin
|
|
249
|
+
entry.handler.call(node.arguments, context)
|
|
250
|
+
rescue PredicateRegistry::UnsupportedValue => e
|
|
251
|
+
unsupported_predicate!(node.name, path, e.message)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
unless result.is_a?(Hash)
|
|
255
|
+
raise InvalidPredicateResultError.new(
|
|
256
|
+
source_name: @source_name,
|
|
257
|
+
path: path,
|
|
258
|
+
predicate: node.name,
|
|
259
|
+
detail: "handler returned #{result.class}, expected Hash"
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
fragment = Internal::Normalizer.normalize_keys(result)
|
|
264
|
+
validate_predicate_fragment!(fragment, node, path) unless entry.internal
|
|
265
|
+
fragment
|
|
266
|
+
rescue InvalidSchemaError => e
|
|
267
|
+
raise InvalidPredicateResultError.new(
|
|
268
|
+
source_name: @source_name,
|
|
269
|
+
path: path,
|
|
270
|
+
predicate: node.name,
|
|
271
|
+
detail: e.message
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def validate_predicate_fragment!(fragment, node, path)
|
|
276
|
+
@dialect.validate!(fragment)
|
|
277
|
+
rescue Dialects::OpenAPI30::InvalidFragmentError => e
|
|
278
|
+
raise InvalidPredicateResultError.new(
|
|
279
|
+
source_name: @source_name,
|
|
280
|
+
path: path,
|
|
281
|
+
predicate: node.name,
|
|
282
|
+
detail: e.message
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def compile_nil(node, path)
|
|
287
|
+
return unsupported_predicate!(:nil?, path, "nil? does not accept semantic arguments") unless node.arguments.empty?
|
|
288
|
+
|
|
289
|
+
NULL_FRAGMENT
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def compile_and(node, path)
|
|
293
|
+
fragments = node.children.map { |child| compile_node(child, path) }
|
|
294
|
+
nulls, schemas = fragments.partition { |fragment| null_fragment?(fragment) }
|
|
295
|
+
if schemas.empty? && nulls.any?
|
|
296
|
+
unsupported_predicate!(:nil?, path,
|
|
297
|
+
"a null-only schema is not valid in OpenAPI 3.0")
|
|
298
|
+
end
|
|
299
|
+
return {"not" => {}} if nulls.any?
|
|
300
|
+
|
|
301
|
+
schemas.reduce({}) { |combined, fragment| Internal::SchemaMerge.combine(combined, fragment) }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def compile_or(node, path)
|
|
305
|
+
alternatives = node.children.flat_map do |child|
|
|
306
|
+
fragment = compile_node(child, path)
|
|
307
|
+
next [fragment] if null_fragment?(fragment)
|
|
308
|
+
|
|
309
|
+
fragment.keys == ["anyOf"] ? fragment.fetch("anyOf") : [fragment]
|
|
310
|
+
end
|
|
311
|
+
alternatives = alternatives.each_with_object([]) { |item, list| list << item unless list.include?(item) }
|
|
312
|
+
null_present = alternatives.any? { |fragment| null_fragment?(fragment) }
|
|
313
|
+
alternatives.reject! { |fragment| null_fragment?(fragment) }
|
|
314
|
+
|
|
315
|
+
unsupported_predicate!(:nil?, path, "a null-only schema is not valid in OpenAPI 3.0") if alternatives.empty?
|
|
316
|
+
|
|
317
|
+
alternatives = alternatives.map { |fragment| @dialect.nullable(fragment) } if null_present
|
|
318
|
+
|
|
319
|
+
alternatives.length == 1 ? alternatives.first : {"anyOf" => alternatives}
|
|
320
|
+
rescue Dialects::OpenAPI30::InvalidFragmentError => e
|
|
321
|
+
unsupported_predicate!(:nil?, path, e.message)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def compile_nullable(node, path)
|
|
325
|
+
fragment = compile_node(node.rule, path)
|
|
326
|
+
unsupported_predicate!(:nil?, path, "a null-only schema is not valid in OpenAPI 3.0") if null_fragment?(fragment)
|
|
327
|
+
|
|
328
|
+
@dialect.nullable(fragment)
|
|
329
|
+
rescue Dialects::OpenAPI30::InvalidFragmentError => e
|
|
330
|
+
unsupported_predicate!(:nil?, path, e.message)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def compile_each(node, path)
|
|
334
|
+
item_schema = compile_node(node.rule, path)
|
|
335
|
+
if null_fragment?(item_schema)
|
|
336
|
+
unsupported_predicate!(:nil?, path, "null-only array items are not valid in OpenAPI 3.0")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
{"type" => "array", "items" => item_schema}
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def null_fragment?(fragment)
|
|
343
|
+
fragment.equal?(NULL_FRAGMENT)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def unsupported_node!(operation, path)
|
|
347
|
+
raise UnsupportedNodeError.new(source_name: @source_name, path: path, operation: operation)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def unsupported_predicate!(predicate, path, detail = nil)
|
|
351
|
+
raise UnsupportedPredicateError.new(
|
|
352
|
+
source_name: @source_name,
|
|
353
|
+
path: path,
|
|
354
|
+
predicate: predicate,
|
|
355
|
+
detail: detail
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def check_unknown_paths!
|
|
360
|
+
unknown_exclusions = @excluded_paths.reject { |path| @known_paths.include?(path) }
|
|
361
|
+
unless unknown_exclusions.empty?
|
|
362
|
+
raise UnknownExclusionPathError,
|
|
363
|
+
"Unknown exclusion path #{unknown_exclusions.first.inspect}."
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
unknown_overrides = @overrides.keys.reject { |path| @known_paths.include?(path) }
|
|
367
|
+
return if unknown_overrides.empty?
|
|
368
|
+
|
|
369
|
+
raise UnknownOverridePathError,
|
|
370
|
+
"Unknown override path #{unknown_overrides.first.inspect}."
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Liminal
|
|
4
|
+
# Generic OpenAPI component registration with normalized collision checks.
|
|
5
|
+
module Components
|
|
6
|
+
NAME_PATTERN = /\A[A-Za-z0-9._-]+\z/
|
|
7
|
+
private_constant :NAME_PATTERN
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Register +schema+ under +name+ and return its local component reference.
|
|
12
|
+
#
|
|
13
|
+
# @param document [Hash] mutable OpenAPI document
|
|
14
|
+
# @param name [String, Symbol] logical component name
|
|
15
|
+
# @param schema [Hash] Schema Object to register
|
|
16
|
+
# @return [Hash] a string-keyed local +$ref+
|
|
17
|
+
def register(document:, name:, schema:)
|
|
18
|
+
raise ArgumentError, "document must be a Hash" unless document.is_a?(Hash)
|
|
19
|
+
raise ArgumentError, "schema must be a Hash" unless schema.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
logical_name = normalize_name(name)
|
|
22
|
+
normalized_schema = Schema.normalize(schema)
|
|
23
|
+
schemas = schema_map(document)
|
|
24
|
+
existing_keys = logical_keys(schemas, logical_name)
|
|
25
|
+
if existing_keys.length > 1
|
|
26
|
+
raise ComponentCollisionError,
|
|
27
|
+
"Component #{logical_name.inspect} is registered under both String and Symbol keys."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if existing_keys.one?
|
|
31
|
+
existing = Schema.normalize(schemas.fetch(existing_keys.first))
|
|
32
|
+
unless existing == normalized_schema
|
|
33
|
+
raise ComponentCollisionError,
|
|
34
|
+
"Component #{logical_name.inspect} is already registered with a different schema."
|
|
35
|
+
end
|
|
36
|
+
else
|
|
37
|
+
schemas[logical_name] = normalized_schema
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
reference(logical_name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reference(name)
|
|
44
|
+
logical_name = normalize_name(name)
|
|
45
|
+
{"$ref" => "#/components/schemas/#{logical_name}".freeze}.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Return the canonical, immutable OpenAPI component key for +name+.
|
|
49
|
+
def normalize_name(name)
|
|
50
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
51
|
+
raise InvalidComponentNameError,
|
|
52
|
+
"Component name must be a String or Symbol; received #{name.class}."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
logical_name = name.to_s.dup.freeze
|
|
56
|
+
return logical_name if NAME_PATTERN.match?(logical_name)
|
|
57
|
+
|
|
58
|
+
expectation = "one or more letters, digits, periods, hyphens, or underscores"
|
|
59
|
+
raise InvalidComponentNameError,
|
|
60
|
+
"Invalid component name #{logical_name.inspect}; expected #{expectation}."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def schema_map(document)
|
|
64
|
+
components = logical_map(document, "components")
|
|
65
|
+
logical_map(components, "schemas")
|
|
66
|
+
end
|
|
67
|
+
private_class_method :schema_map
|
|
68
|
+
|
|
69
|
+
def logical_map(container, name)
|
|
70
|
+
keys = logical_keys(container, name)
|
|
71
|
+
raise ArgumentError, "#{name} cannot be present under both String and Symbol keys" if keys.length > 1
|
|
72
|
+
|
|
73
|
+
if keys.empty?
|
|
74
|
+
container[name] = {}
|
|
75
|
+
return container.fetch(name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
value = container.fetch(keys.first)
|
|
79
|
+
raise ArgumentError, "#{name} must be a Hash" unless value.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
private_class_method :logical_map
|
|
84
|
+
|
|
85
|
+
def logical_keys(container, name)
|
|
86
|
+
[name, name.to_sym].select { |key| container.key?(key) }
|
|
87
|
+
end
|
|
88
|
+
private_class_method :logical_keys
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Liminal
|
|
4
|
+
module Dialects
|
|
5
|
+
class OpenAPI30
|
|
6
|
+
# Validates the fixed-field objects embedded in an OpenAPI 3.0 Schema
|
|
7
|
+
# Object. Keeping these shapes here leaves the dialect focused on schema
|
|
8
|
+
# keywords and recursive child schemas.
|
|
9
|
+
module MetadataValidator
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def validate!(schema, &)
|
|
13
|
+
validate_discriminator!(schema, &)
|
|
14
|
+
validate_xml!(schema, &)
|
|
15
|
+
validate_external_docs!(schema, &)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def validate_discriminator!(schema, &)
|
|
19
|
+
return unless schema.key?("discriminator")
|
|
20
|
+
|
|
21
|
+
discriminator = fixed_object!(
|
|
22
|
+
schema.fetch("discriminator"),
|
|
23
|
+
"discriminator",
|
|
24
|
+
%w[propertyName mapping],
|
|
25
|
+
&
|
|
26
|
+
)
|
|
27
|
+
property_name = discriminator["propertyName"]
|
|
28
|
+
unless property_name.is_a?(String) && !property_name.empty?
|
|
29
|
+
raise InvalidFragmentError, "discriminator.propertyName must be a non-empty string"
|
|
30
|
+
end
|
|
31
|
+
return unless discriminator.key?("mapping")
|
|
32
|
+
|
|
33
|
+
mapping = discriminator.fetch("mapping")
|
|
34
|
+
valid = mapping.is_a?(Hash) &&
|
|
35
|
+
mapping.all? { |key, value| key.is_a?(String) && value.is_a?(String) }
|
|
36
|
+
raise InvalidFragmentError, "discriminator.mapping must map strings to strings" unless valid
|
|
37
|
+
end
|
|
38
|
+
private_class_method :validate_discriminator!
|
|
39
|
+
|
|
40
|
+
def validate_xml!(schema, &)
|
|
41
|
+
return unless schema.key?("xml")
|
|
42
|
+
|
|
43
|
+
xml = fixed_object!(
|
|
44
|
+
schema.fetch("xml"),
|
|
45
|
+
"xml",
|
|
46
|
+
%w[name namespace prefix attribute wrapped],
|
|
47
|
+
&
|
|
48
|
+
)
|
|
49
|
+
validate_fields!(xml, %w[name prefix], String, "a string", "xml")
|
|
50
|
+
validate_fields!(xml, %w[attribute wrapped], [TrueClass, FalseClass], "boolean", "xml")
|
|
51
|
+
return unless xml.key?("namespace")
|
|
52
|
+
|
|
53
|
+
namespace = xml.fetch("namespace")
|
|
54
|
+
return if namespace.is_a?(String) && absolute_uri?(namespace)
|
|
55
|
+
|
|
56
|
+
raise InvalidFragmentError, "xml.namespace must be an absolute URI string"
|
|
57
|
+
end
|
|
58
|
+
private_class_method :validate_xml!
|
|
59
|
+
|
|
60
|
+
def validate_external_docs!(schema, &)
|
|
61
|
+
return unless schema.key?("externalDocs")
|
|
62
|
+
|
|
63
|
+
external_docs = fixed_object!(
|
|
64
|
+
schema.fetch("externalDocs"),
|
|
65
|
+
"externalDocs",
|
|
66
|
+
%w[description url],
|
|
67
|
+
&
|
|
68
|
+
)
|
|
69
|
+
url = external_docs["url"]
|
|
70
|
+
unless url.is_a?(String) && !url.empty? && uri_reference?(url)
|
|
71
|
+
raise InvalidFragmentError, "externalDocs.url must be a valid URI-reference string"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
validate_fields!(external_docs, ["description"], String, "a string", "externalDocs")
|
|
75
|
+
end
|
|
76
|
+
private_class_method :validate_external_docs!
|
|
77
|
+
|
|
78
|
+
def fixed_object!(value, keyword, fields)
|
|
79
|
+
raise InvalidFragmentError, "#{keyword} must be an OpenAPI-compatible object" unless value.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
unknown = value.keys.reject { |key| fields.include?(key) || key.start_with?("x-") }
|
|
82
|
+
unless unknown.empty?
|
|
83
|
+
raise InvalidFragmentError, "#{keyword} contains unsupported field #{unknown.first.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
value.each do |key, extension|
|
|
87
|
+
next unless key.start_with?("x-")
|
|
88
|
+
next if yield(extension)
|
|
89
|
+
|
|
90
|
+
raise InvalidFragmentError, "#{keyword}.#{key} must be an OpenAPI-compatible value"
|
|
91
|
+
end
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
private_class_method :fixed_object!
|
|
95
|
+
|
|
96
|
+
def validate_fields!(object, fields, expected_classes, expectation, object_name)
|
|
97
|
+
fields.each do |field|
|
|
98
|
+
next unless object.key?(field)
|
|
99
|
+
next if Array(expected_classes).any? { |type| object.fetch(field).is_a?(type) }
|
|
100
|
+
|
|
101
|
+
raise InvalidFragmentError, "#{object_name}.#{field} must be #{expectation}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
private_class_method :validate_fields!
|
|
105
|
+
|
|
106
|
+
def absolute_uri?(value)
|
|
107
|
+
URI.parse(value).absolute?
|
|
108
|
+
rescue URI::InvalidURIError
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
private_class_method :absolute_uri?
|
|
112
|
+
|
|
113
|
+
def uri_reference?(value)
|
|
114
|
+
URI.parse(value)
|
|
115
|
+
true
|
|
116
|
+
rescue URI::InvalidURIError
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
private_class_method :uri_reference?
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|