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,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