Module: MCPClient::SchemaValidator::References
- Included in:
- MCPClient::SchemaValidator
- Defined in:
- lib/mcp_client/schema_validator/references.rb
Overview
Resolution of local $ref values: JSON pointer fragments (RFC 6901)
and plain-name fragments naming an anchor. Nothing here ever fetches:
a reference outside the document is reported as external. Extended
into SchemaValidator, so the methods are its own.
Plain names are scoped to their schema resource (JSON Schema 2020-12
Core Sections 8.2.1 and 8.2.2): a subschema whose $id is a URI
starts a new resource, and #name names an anchor of the resource the
referencing schema belongs to — never one of an embedded resource, and
never one of the enclosing document from inside an embedded resource.
Instance Method Summary collapse
-
#adopt_reached_target(target, resource, index, raw: false) ⇒ Object
A schema a pointer reaches outside every indexed position (inside
default, another data keyword or a vendor member) is still part of the resource it was reached from: it (and what it holds) is indexed on arrival, so a$refwritten anywhere in it resolves within that resource and its nested schemas follow the dialect it adopts, and what the document holds as data is normalized like everywhere else (values are kept as given for equality; a subtree resolved as a schema gets one string-keyed copy, memoized by identity). -
#adopt_step(node, resource, index, mode) ⇒ Array(Object, Hash, Symbol)
Adopt the object a pointer is about to step through when the document holds it as data, and follow the resource the index attributes it to.
-
#anchor_index(root, dialect) ⇒ Hash
Every plain-name anchor in the document, per schema resource:
$anchor(and$dynamicAnchor) in 2019-09 / 2020-12,$id: "#name"in draft-07. -
#anchor_names(schema, dialect) ⇒ Array<String>
The plain names a schema object declares.
-
#decode_component(component) ⇒ String?
Percent-decode one component.
-
#decoded_fragment(ref) ⇒ String?
The decoded fragment of a reference (RFC 3986 Section 2.1), or nil when what the peer wrote does not decode to readable text: a malformed escape ("a%ZZ") and escapes that are not valid UTF-8 name nothing in this document, and reading them must never raise out of the validation.
-
#dynamic_binding(schema, keyword, root, dialect, resolver) ⇒ Array
How a dynamic reference binds (JSON Schema 2020-12 Core Section 8.2.3.2; 2019-09 Section 8.2.4.2.2).
-
#each_foreign_definition(schema, dialect, &block) ⇒ void
Yield the definitions held in the bag the dialect does not define (
$defsunder draft-07, which predates it): unknown to the dialect, but pointer-addressable all the same. -
#each_walked_position(schema, dialect) ⇒ void
Yield the schema positions the dialect walks under a schema object: under a draft-07
$refonly thedefinitionsbag, else every subschema (the dialect's definition bag included). -
#enter_resource(index, schema, resource, dialect, named) ⇒ Array(Hash, String, Boolean)
Enter the schema resource a position starts, if it starts one: a resource is a schema wherever it sits (one reached through a bag the dialect does not walk names its own anchors, though nothing outside it can see them), it may declare its own dialect, and its
$idestablishes the base its references resolve against. -
#external_ref?(ref, root = nil, dialect = nil, resolver = nil, from: nil) ⇒ Boolean
A reference that does not point inside this document, so using it would need a retrieval that never happens.
-
#index_positions(index, pending) ⇒ void
Index every schema position reachable from the pending seeds, within the visit and depth bounds the whole index runs under (an adopted pointer target is seeded here too, so it shares them).
-
#indexed_dialect(schema, resolver) ⇒ String?
The dialect the memoized anchor index recorded for a schema object's resource, or nil when the object was not indexed.
-
#lexical_depths(root, dialect) ⇒ Hash{Hash => Integer}
The lexical nesting depth of every schema object reachable from the root (subschema positions, definition bags of any dialect), so a referenced target is bounded by where it is written, not by where it is referenced from: neither member order nor reference fan-out can change the verdict.
-
#normalized_copy(target, index) ⇒ Hash
The one string-keyed copy of a subtree the document holds as data, memoized by the identity of the object it holds.
-
#outermost_dynamic_anchor(index, scope, name) ⇒ Hash?
The schema declaring
$dynamicAnchor: namein the outermost resource of the dynamic scope that declares it — the scope being the resources the evaluation actually entered, outermost first. -
#outermost_recursive_anchor(index, scope, dialect) ⇒ Hash?
The outermost resource of the dynamic scope whose
$recursiveAnchoris true (2019-09 Core Section 8.2.4.2.2); the target of a$recursiveRefis the resource root itself. -
#pointer_child(node, token) ⇒ Object
The member a pointer token selects, or UNRESOLVED.
-
#pointer_origin(index, root, ref, from) ⇒ Array(Hash, String), Array(nil, nil)
The resource a reference's pointer is read inside, and the bare fragment that applies there.
-
#pointer_position(ref, root, dialect, counter, from) ⇒ Array(Integer, Boolean)?
The depth and whether the pointer crossed an opaque keyword; nil when it cannot be followed.
-
#pointer_step(walk, node, token) ⇒ void
Advance one pointer token: in schema mode the node's own dialect and known depth apply and the keyword decides how the next token counts; inside a map or array keyword the member is the step; under an opaque keyword every token is a step.
-
#pointer_step_mode(node, token, dialect) ⇒ Symbol
How a keyword of a schema object holds what its pointer token reaches.
-
#pointer_tokens(ref) ⇒ Array<String>?
The decoded RFC 6901 tokens of a fragment pointer.
-
#record_anchor_names(index, resource, schema, dialect) ⇒ void
Record the plain names a schema object declares for its resource; a name already bound to another object of the same resource is a duplicate (anchor names are unique within a resource).
-
#referenced_position_depth(ref, root, dialect, counter, from) ⇒ Integer?
The lexical depth of the value a pointer reference reaches, counted in schema steps along the (percent-decoded) pointer from its resource root: a keyword holding one subschema is one step, a map or array of subschemas is one step per member (
#/properties/band#/allOf/0are both one below the enclosing schema), and every token under a data or unknown keyword is a step, so a document hidden insidedefault,enum,const,examplesor a vendor keyword obeys the same bound as one written in a schema position. -
#register_resource_base(index, resource, parent_base, declared: true) ⇒ void
Record the base URI a schema resource establishes (RFC 3986 Section 5.1.1: an
$idis resolved against the base in force where it is written), and the resource that base names. -
#resolve_adopted_pointer(resource, ref, index) ⇒ Object
Resolve a JSON pointer within a schema resource, adopting on the way whatever subtree the pointer enters through a data keyword (
defaultand the rest): that subtree is normalized and indexed once — memoized by the identity of the object the document holds — and the remaining tokens are walked through the copy. -
#resolve_reference(root, ref, dialect, resolver, from: nil) ⇒ Object
Resolve a local reference: a JSON pointer fragment, or a plain-name fragment naming an anchor.
-
#resource_root?(schema, dialect) ⇒ Boolean
#resource_start? in the dialect in force: under draft-07 a
$refreplaces its whole schema object,$idand$schemaincluded, so nothing beside it starts a resource. -
#resource_start?(schema) ⇒ Boolean
Whether a schema object starts a new schema resource: its
$idis a URI rather than a bare fragment (a draft-07$id: "#name"is a plain-name identifier, not a base). -
#retarget_reference(index, resource, ref) ⇒ Array(Hash, String), Array(nil, nil)
The bundled resource a reference outside the fragment space names, with the bare fragment left to resolve inside it.
Instance Method Details
#adopt_reached_target(target, resource, index, raw: false) ⇒ Object
A schema a pointer reaches outside every indexed position (inside
default, another data keyword or a vendor member) is still part of
the resource it was reached from: it (and what it holds) is indexed
on arrival, so a $ref written anywhere in it resolves within that
resource and its nested schemas follow the dialect it adopts, and
what the document holds as data is normalized like everywhere else
(values are kept as given for equality; a subtree resolved as a
schema gets one string-keyed copy, memoized by identity).
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/mcp_client/schema_validator/references.rb', line 180 def adopt_reached_target(target, resource, index, raw: false) return target unless target.is_a?(Hash) # Copied under the same structural budget as the root document, # whatever key form it arrived in (over the wire every key is a # string): a data keyword may not hide an unbounded map behind a # pointer. target = normalized_copy(target, index) if raw && !index[:resources].key?(target) # An indexed position was already walked, charged and attributed. return target if index[:resources].key?(target) # The adopted target is indexed like any schema position, so its own # `$id` / `$schema` and the positions below it are seen: within the # bounds the index already runs under. It declares no names of its # own — `resource_root?` turns naming on where an `$id` really starts # a resource — since a data keyword is not a schema position and an # `$anchor` written inside one names nothing in the document (Core # Sections 8.2.2 and 4.3.1). index_positions(index, [[target, resource, 0, index[:dialects][resource], false]]) target end |
#adopt_step(node, resource, index, mode) ⇒ Array(Object, Hash, Symbol)
Adopt the object a pointer is about to step through when the document holds it as data, and follow the resource the index attributes it to.
159 160 161 162 163 164 165 166 167 |
# File 'lib/mcp_client/schema_validator/references.rb', line 159 def adopt_step(node, resource, index, mode) return [node, resource, mode] unless node.is_a?(Hash) if mode == :data node = adopt_reached_target(node, resource, index, raw: true) mode = :schema end [node, index[:resources][node] || resource, mode] end |
#anchor_index(root, dialect) ⇒ Hash
Every plain-name anchor in the document, per schema resource:
$anchor (and $dynamicAnchor) in 2019-09 / 2020-12, $id: "#name"
in draft-07. The walk is bounded like the preflight walk and follows
the dialect of each resource (an embedded resource may declare its
own $schema). Identifiers are taken only from schema positions the
dialect walks (JSON Schema 2020-12 Core Sections 8.2.2 and 4.3.1: an
identifier belongs to a schema object, and the value of a keyword
the dialect does not define is not a schema): a definition bag the
dialect does not define stays reachable through JSON pointers, and
its objects are attributed to their lexical resource, but it is
never a source of names — nor is anything beside a draft-07 $ref
apart from its definitions.
227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/mcp_client/schema_validator/references.rb', line 227 def anchor_index(root, dialect) index = { resources: {}.compare_by_identity, anchors: {}.compare_by_identity, dialects: {}.compare_by_identity, bases: {}.compare_by_identity, by_base: {}, duplicates: [], duplicate_ids: [], visited: 0, truncated: false } index[:dialects][root] = dialect # The document's own base: the root's `$id` where it declares one, # else the empty reference. Relative references resolve against each # other either way, which is what a bundled document needs. register_resource_base(index, root, '', declared: root.is_a?(Hash) && resource_root?(root, dialect)) index_positions(index, [[root, root, 0, dialect, true]]) index end |
#anchor_names(schema, dialect) ⇒ Array<String>
Returns the plain names a schema object declares.
445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/mcp_client/schema_validator/references.rb', line 445 def anchor_names(schema, dialect) names = if dialect == DRAFT_07 # draft-07 Core Section 8.2.3: only an $id that is exactly a # fragment is a plain-name identifier. An $id is a URI # reference, so its fragment is percent-decoded (RFC 3986 # Section 2.1) exactly as a $ref's is: `$id: "#foo%2Dbar"` # declares the name "foo-bar", which is what # `$ref: "#foo%2Dbar"` — decoded the same way — looks for. id = schema['$id'] [id.is_a?(String) && id.start_with?('#') ? decoded_fragment(id) : nil] else %w[$anchor $dynamicAnchor].map { |k| schema[k] if keyword_known?(k, dialect) } end names.select { |name| anchor_name?(name, dialect) } end |
#decode_component(component) ⇒ String?
Percent-decode one component.
109 110 111 112 113 |
# File 'lib/mcp_client/schema_validator/references.rb', line 109 def decode_component(component) URI.decode_uri_component(component) rescue ArgumentError nil end |
#decoded_fragment(ref) ⇒ String?
The decoded fragment of a reference (RFC 3986 Section 2.1), or nil when what the peer wrote does not decode to readable text: a malformed escape ("a%ZZ") and escapes that are not valid UTF-8 name nothing in this document, and reading them must never raise out of the validation. The undecoded text is never substituted -- it would make "#/$defs/a%ZZ" resolve onto a literal "a%ZZ" member and pass a reference the peer never wrote off as valid.
101 102 103 104 |
# File 'lib/mcp_client/schema_validator/references.rb', line 101 def decoded_fragment(ref) decoded = decode_component(ref.delete_prefix('#')) decoded if decoded&.valid_encoding? end |
#dynamic_binding(schema, keyword, root, dialect, resolver) ⇒ Array
How a dynamic reference binds (JSON Schema 2020-12 Core Section
8.2.3.2; 2019-09 Section 8.2.4.2.2). A reference whose initial
target declares no matching dynamic anchor is the plain reference it
resolves to (:plain). One that does re-binds to the declaration in
the OUTERMOST resource of the dynamic scope — the resources the
evaluation entered on its way here, which MCPClient::SchemaValidator.entered_scope?
records as they are entered (:bound, with the target). What the
document holds elsewhere decides nothing: a resource the instance
never entered is not in the scope, so a duplicate anchor there is
neither ambiguity nor a reason to leave the reference unevaluated.
Outside a validation there is no scope to read, and every reference
is the plain one it resolves to — which is what the preflight, whose
job is that the reference resolves at all, needs.
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 |
# File 'lib/mcp_client/schema_validator/references.rb', line 389 def dynamic_binding(schema, keyword, root, dialect, resolver) ref = schema[keyword] return [:plain] unless ref.is_a?(String) && !external_ref?(ref, root, dialect, resolver, from: schema) target = resolve_reference(root, ref, dialect, resolver, from: schema) return [:plain] if target.equal?(UNRESOLVED) return [:plain] unless target.is_a?(Hash) index = resolver[:anchors] scope = resolver[:scope] bound = if keyword == '$recursiveRef' return [:plain] unless target['$recursiveAnchor'] == true outermost_recursive_anchor(index, scope, dialect) else fragment = ref.include?('#') ? decoded_fragment(ref[ref.index('#')..]) : nil return [:plain] if fragment.nil? || fragment.empty? || fragment.start_with?('/') return [:plain] unless target['$dynamicAnchor'] == fragment outermost_dynamic_anchor(index, scope, fragment) end bound ? [:bound, bound] : [:plain] end |
#each_foreign_definition(schema, dialect, &block) ⇒ void
This method returns an undefined value.
Yield the definitions held in the bag the dialect does not define
($defs under draft-07, which predates it): unknown to the dialect,
but pointer-addressable all the same.
601 602 603 604 605 606 607 |
# File 'lib/mcp_client/schema_validator/references.rb', line 601 def each_foreign_definition(schema, dialect, &block) %w[$defs definitions].each do |keyword| next if keyword_known?(keyword, dialect) schema[keyword].each_value(&block) if schema[keyword].is_a?(Hash) end end |
#each_walked_position(schema, dialect) ⇒ void
This method returns an undefined value.
Yield the schema positions the dialect walks under a schema object:
under a draft-07 $ref only the definitions bag, else every
subschema (the dialect's definition bag included).
326 327 328 329 330 331 332 |
# File 'lib/mcp_client/schema_validator/references.rb', line 326 def each_walked_position(schema, dialect, &) if dialect == DRAFT_07 && schema.key?('$ref') each_definition(schema, dialect, &) else each_subschema(schema, dialect, &) end end |
#enter_resource(index, schema, resource, dialect, named) ⇒ Array(Hash, String, Boolean)
Enter the schema resource a position starts, if it starts one: a
resource is a schema wherever it sits (one reached through a bag the
dialect does not walk names its own anchors, though nothing outside
it can see them), it may declare its own dialect, and its $id
establishes the base its references resolve against.
300 301 302 303 304 305 306 307 308 |
# File 'lib/mcp_client/schema_validator/references.rb', line 300 def enter_resource(index, schema, resource, dialect, named) return [resource, dialect, named] unless resource_root?(schema, dialect) parent_base = index[:bases][resource] || '' dialect = (schema, dialect) || dialect index[:dialects][schema] = dialect register_resource_base(index, schema, parent_base) [schema, dialect, true] end |
#external_ref?(ref, root = nil, dialect = nil, resolver = nil, from: nil) ⇒ Boolean
A reference that does not point inside this document, so using it
would need a retrieval that never happens. A bare fragment is always
local; anything else is resolved against the base URI of the
resource holding it (RFC 3986 Section 5.2) and is local when the
document bundles a resource whose $id is that URI (JSON Schema
2020-12 Core Section 9.3.1) -- the empty reference, which names the
base itself, included. Without the document and its index only the
syntactic answer is available, and a reference outside the fragment
space is external.
33 34 35 36 37 38 39 40 41 |
# File 'lib/mcp_client/schema_validator/references.rb', line 33 def external_ref?(ref, root = nil, dialect = nil, resolver = nil, from: nil) return false if ref.start_with?('#') return true unless root.is_a?(Hash) && resolver index = (resolver[:anchors] ||= anchor_index(root, dialect)) return true if from.is_a?(Hash) && !index[:resources].key?(from) retarget_reference(index, (from && index[:resources][from]) || root, ref).first.nil? end |
#index_positions(index, pending) ⇒ void
This method returns an undefined value.
Index every schema position reachable from the pending seeds, within the visit and depth bounds the whole index runs under (an adopted pointer target is seeded here too, so it shares them).
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/mcp_client/schema_validator/references.rb', line 268 def index_positions(index, pending) until pending.empty? || index[:visited] >= MAX_SUBSCHEMAS schema, resource, depth, dialect, named = pending.shift next unless schema.is_a?(Hash) next if index[:resources].key?(schema) # A schema below the depth bound is left unindexed just like one # beyond the visit bound: a reference from it (or an `$id` there) # would otherwise resolve under guesses. (index[:truncated] = true) && next if depth > MAX_SCHEMA_DEPTH index[:visited] += 1 resource, dialect, named = enter_resource(index, schema, resource, dialect, named) index[:resources][schema] = resource if named && !(dialect == DRAFT_07 && schema.key?('$ref')) record_anchor_names(index, resource, schema, dialect) end each_walked_position(schema, dialect) { |sub| pending << [sub, resource, depth + 1, dialect, named] } each_foreign_definition(schema, dialect) { |sub| pending << [sub, resource, depth + 1, dialect, false] } end # Objects left unindexed at the bound would resolve and validate # under guesses; the index says so and the schema is unusable. index[:truncated] ||= pending.any? { |schema, *| schema.is_a?(Hash) && !index[:resources].key?(schema) } end |
#indexed_dialect(schema, resolver) ⇒ String?
The dialect the memoized anchor index recorded for a schema object's resource, or nil when the object was not indexed.
339 340 341 342 343 344 345 |
# File 'lib/mcp_client/schema_validator/references.rb', line 339 def indexed_dialect(schema, resolver) index = resolver[:anchors] return nil unless index resource = index[:resources][schema] resource && index[:dialects][resource] end |
#lexical_depths(root, dialect) ⇒ Hash{Hash => Integer}
The lexical nesting depth of every schema object reachable from the root (subschema positions, definition bags of any dialect), so a referenced target is bounded by where it is written, not by where it is referenced from: neither member order nor reference fan-out can change the verdict.
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 |
# File 'lib/mcp_client/schema_validator/references.rb', line 467 def lexical_depths(root, dialect) depths = {}.compare_by_identity pending = [[root, 0, dialect]] while (schema, depth, current = pending.shift) next unless schema.is_a?(Hash) && !depths.key?(schema) depths[schema] = depth break if depths.size > MAX_SUBSCHEMAS * 2 # An embedded resource's positions follow its own dialect. current = (schema, current) || current each_subschema(schema, current) { |sub| pending << [sub, depth + 1, current] } each_foreign_definition(schema, current) { |sub| pending << [sub, depth + 1, current] } end depths end |
#normalized_copy(target, index) ⇒ Hash
The one string-keyed copy of a subtree the document holds as data, memoized by the identity of the object it holds.
205 206 207 208 209 |
# File 'lib/mcp_client/schema_validator/references.rb', line 205 def normalized_copy(target, index) copies = (index[:normalized] ||= {}.compare_by_identity) index[:budget] ||= { objects: 0, deadline: nil } copies[target] ||= deep_stringify(target, 0, index[:budget]) end |
#outermost_dynamic_anchor(index, scope, name) ⇒ Hash?
The schema declaring $dynamicAnchor: name in the outermost resource
of the dynamic scope that declares it — the scope being the resources
the evaluation actually entered, outermost first. A resource the
instance never entered declares nothing for this reference, however
many of them the document holds; and where no entered resource
declares the name, the caller keeps the target the reference resolved
to on its own, which is what the specification's "otherwise behave as
$ref" says.
425 426 427 428 429 430 431 |
# File 'lib/mcp_client/schema_validator/references.rb', line 425 def outermost_dynamic_anchor(index, scope, name) Array(scope).each do |resource| declaring = index[:anchors][resource]&.[](name) return declaring if declaring.is_a?(Hash) && declaring['$dynamicAnchor'] == name end nil end |
#outermost_recursive_anchor(index, scope, dialect) ⇒ Hash?
The outermost resource of the dynamic scope whose $recursiveAnchor
is true (2019-09 Core Section 8.2.4.2.2); the target of a
$recursiveRef is the resource root itself.
437 438 439 440 441 442 |
# File 'lib/mcp_client/schema_validator/references.rb', line 437 def outermost_recursive_anchor(index, scope, dialect) Array(scope).find do |resource| resource.is_a?(Hash) && resource['$recursiveAnchor'] == true && (index[:dialects][resource] || dialect) != DRAFT_07 end end |
#pointer_child(node, token) ⇒ Object
Returns the member a pointer token selects, or UNRESOLVED.
575 576 577 578 579 580 581 |
# File 'lib/mcp_client/schema_validator/references.rb', line 575 def pointer_child(node, token) case node when Hash then node.key?(token) ? node[token] : UNRESOLVED when Array then token.match?(/\A(0|[1-9]\d*)\z/) && token.to_i < node.length ? node[token.to_i] : UNRESOLVED else UNRESOLVED end end |
#pointer_origin(index, root, ref, from) ⇒ Array(Hash, String), Array(nil, nil)
The resource a reference's pointer is read inside, and the bare
fragment that applies there. A reference written as an absolute URI
into the bundled document (urn:root#/x/y) addresses the very
position its bare spelling (#/x/y) does, so the pointer is followed
— and the target accounted for — the same way whichever the peer
wrote (JSON Schema 2020-12 Core Section 9.3.1).
558 559 560 561 562 563 |
# File 'lib/mcp_client/schema_validator/references.rb', line 558 def pointer_origin(index, root, ref, from) resource = (from && index[:resources][from]) || root return [resource, ref] if ref.start_with?('#') retarget_reference(index, resource, ref) end |
#pointer_position(ref, root, dialect, counter, from) ⇒ Array(Integer, Boolean)?
Returns the depth and whether the pointer crossed an opaque keyword; nil when it cannot be followed.
505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 |
# File 'lib/mcp_client/schema_validator/references.rb', line 505 def pointer_position(ref, root, dialect, counter, from) index = (counter[:anchors] ||= anchor_index(root, dialect)) node, ref = pointer_origin(index, root, ref, from) return nil unless node tokens = pointer_tokens(ref) return nil unless tokens depths = counter[:depths] || {} walk = { index: index, depths: depths, dialect: index[:dialects][node] || dialect, depth: depths[node] || 0, mode: :schema, opaque: false, visited: true } tokens.each do |token| child = pointer_child(node, token) return nil if child.equal?(UNRESOLVED) pointer_step(walk, node, token) node = child end [walk[:depth], walk[:opaque], walk[:visited]] end |
#pointer_step(walk, node, token) ⇒ void
This method returns an undefined value.
Advance one pointer token: in schema mode the node's own dialect and known depth apply and the keyword decides how the next token counts; inside a map or array keyword the member is the step; under an opaque keyword every token is a step.
531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 |
# File 'lib/mcp_client/schema_validator/references.rb', line 531 def pointer_step(walk, node, token) unless walk[:mode] == :schema walk[:depth] += 1 walk[:mode] = :schema if %i[map array].include?(walk[:mode]) return end if node.is_a?(Hash) walk[:depth] = walk[:depths][node] if !walk[:opaque] && walk[:depths].key?(node) walk[:dialect] = walk[:index][:dialects][node] || (node, walk[:dialect]) || walk[:dialect] end walk[:mode] = pointer_step_mode(node, token, walk[:dialect]) walk[:opaque] ||= walk[:mode] == :opaque # The preflight walk does not descend into an opaque keyword, nor # into anything beside a draft-07 $ref but its definitions. walk[:visited] &&= walk[:mode] != :opaque && !(walk[:dialect] == DRAFT_07 && node.is_a?(Hash) && node.key?('$ref') && token != 'definitions') walk[:depth] += 1 unless %i[map array].include?(walk[:mode]) end |
#pointer_step_mode(node, token, dialect) ⇒ Symbol
How a keyword of a schema object holds what its pointer token reaches.
A keyword the dialect in force does not define (prefixItems under
draft-07, additionalItems under 2020-12) is opaque data there.
588 589 590 591 592 593 594 595 |
# File 'lib/mcp_client/schema_validator/references.rb', line 588 def pointer_step_mode(node, token, dialect) return :opaque unless node.is_a?(Hash) && keyword_known?(token, dialect) return :map if SUBSCHEMA_MAP_KEYWORDS.include?(token) return :array if SUBSCHEMA_ARRAY_KEYWORDS.include?(token) || (token == 'items' && node[token].is_a?(Array)) return :schema if SUBSCHEMA_KEYWORDS.include?(token) :opaque end |
#pointer_tokens(ref) ⇒ Array<String>?
The decoded RFC 6901 tokens of a fragment pointer.
567 568 569 570 571 572 |
# File 'lib/mcp_client/schema_validator/references.rb', line 567 def pointer_tokens(ref) fragment = decoded_fragment(ref) return nil unless fragment&.start_with?('/') fragment.split('/', -1).drop(1).map { |token| token.gsub('~1', '/').gsub('~0', '~') } end |
#record_anchor_names(index, resource, schema, dialect) ⇒ void
This method returns an undefined value.
Record the plain names a schema object declares for its resource; a name already bound to another object of the same resource is a duplicate (anchor names are unique within a resource).
314 315 316 317 318 319 320 |
# File 'lib/mcp_client/schema_validator/references.rb', line 314 def record_anchor_names(index, resource, schema, dialect) names = (index[:anchors][resource] ||= {}) anchor_names(schema, dialect).each do |name| index[:duplicates] << name if names.key?(name) && !names[name].equal?(schema) names[name] ||= schema end end |
#referenced_position_depth(ref, root, dialect, counter, from) ⇒ Integer?
The lexical depth of the value a pointer reference reaches, counted
in schema steps along the (percent-decoded) pointer from its resource
root: a keyword holding one subschema is one step, a map or array of
subschemas is one step per member (#/properties/b and #/allOf/0
are both one below the enclosing schema), and every token under a
data or unknown keyword is a step, so a document hidden inside
default, enum, const, examples or a vendor keyword obeys the
same bound as one written in a schema position. Keywords are
classified by the dialect in force at each node (an embedded
resource's own $schema takes over when the pointer enters it). A
schema object whose depth the lexical index knows resets the count
to that depth — unless the pointer already passed through an opaque
keyword, after which every token counts (the index placed such an
object under another dialect's grammar).
499 500 501 |
# File 'lib/mcp_client/schema_validator/references.rb', line 499 def referenced_position_depth(ref, root, dialect, counter, from) pointer_position(ref, root, dialect, counter, from)&.first end |
#register_resource_base(index, resource, parent_base, declared: true) ⇒ void
This method returns an undefined value.
Record the base URI a schema resource establishes (RFC 3986 Section
5.1.1: an $id is resolved against the base in force where it is
written), and the resource that base names. The first declaration of
a base wins, as the first declaration of an anchor name does.
246 247 248 249 250 251 252 253 254 255 256 257 258 259 |
# File 'lib/mcp_client/schema_validator/references.rb', line 246 def register_resource_base(index, resource, parent_base, declared: true) id = declared && resource.is_a?(Hash) ? resource['$id'] : nil base = (id.is_a?(String) ? merge_uri(parent_base, id) : parent_base) || parent_base index[:bases][resource] = base known = index[:by_base][base] return index[:by_base][base] = resource if known.nil? # Two resources answering to one URI: which one a reference lands on # would depend on the order the walk met them in, so the document is # unusable rather than resolved by luck (JSON Schema 2020-12 Core # Section 9.1.2). Only a declared `$id` can collide; the base a # resource merely inherits is its parent's, already registered. index[:duplicate_ids] << base if id.is_a?(String) && !known.equal?(resource) end |
#resolve_adopted_pointer(resource, ref, index) ⇒ Object
Resolve a JSON pointer within a schema resource, adopting on the way
whatever subtree the pointer enters through a data keyword (default
and the rest): that subtree is normalized and indexed once — memoized
by the identity of the object the document holds — and the remaining
tokens are walked through the copy. So every pointer into it lands on
the objects the index already knows, with the resource, dialect and
subschema charge it gave them: a nested pointer is not a second copy
attributed to the referrer (JSON Schema 2020-12 Core Sections 8.1.1
and 8.2.1: a schema belongs to the resource of its nearest $id
ancestor, wherever a reference reached it from).
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/mcp_client/schema_validator/references.rb', line 129 def resolve_adopted_pointer(resource, ref, index) fragment = decoded_fragment(ref) return UNRESOLVED unless fragment return adopt_reached_target(resource, resource, index) if fragment.empty? return UNRESOLVED unless fragment.start_with?('/') node = resource mode = :schema # RFC 6901 Section 5: the pointer "/" is the member named "", so the # leading separator is dropped rather than split off ("" splits to no # tokens at all, which would read "#/" as the whole document). fragment.split('/', -1).drop(1).each do |token| # RFC 6901 Section 3: "~" is only ever followed by "0" or "1". return UNRESOLVED if token.match?(/~(?![01])/) node, resource, mode = adopt_step(node, resource, index, mode) token = token.gsub('~1', '/').gsub('~0', '~') child = pointer_child(node, token) return UNRESOLVED if child.equal?(UNRESOLVED) mode = member_mode(token, child, mode) node = child end adopt_reached_target(node, resource, index, raw: mode == :data) end |
#resolve_reference(root, ref, dialect, resolver, from: nil) ⇒ Object
Resolve a local reference: a JSON pointer fragment, or a plain-name
fragment naming an anchor. Both are relative to the schema resource
the referencing schema belongs to (JSON Schema 2020-12 Core Section
8.2.1): inside an embedded resource # is that resource and
#/$defs/x its own definitions, never the enclosing document's.
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/mcp_client/schema_validator/references.rb', line 67 def resolve_reference(root, ref, dialect, resolver, from: nil) index = (resolver[:anchors] ||= anchor_index(root, dialect)) # A referring schema the index never reached has no known resource: # resolving against the document root would apply the wrong `#`. return UNRESOLVED if from.is_a?(Hash) && !index[:resources].key?(from) resource = (from && index[:resources][from]) || root # A reference outside the fragment space names a resource by URI: # the document may bundle it, and then the fragment applies there. unless ref.start_with?('#') resource, ref = retarget_reference(index, resource, ref) return UNRESOLVED unless resource end raw = ref.delete_prefix('#') return resolve_adopted_pointer(resource, ref, index) if raw.empty? || raw.start_with?('/', '%2F', '%2f') # A plain-name fragment is percent-decoded like a pointer fragment # (RFC 3986 Section 2.1): "#foo%2Dbar" names the anchor "foo-bar". # What counts as a name is the target resource's dialect's business. fragment = decoded_fragment(ref) return UNRESOLVED unless anchor_name?(fragment, index[:dialects][resource] || dialect) index[:anchors].fetch(resource, {}).fetch(fragment, UNRESOLVED) end |
#resource_root?(schema, dialect) ⇒ Boolean
#resource_start? in the dialect in force: under draft-07 a $ref
replaces its whole schema object, $id and $schema included, so
nothing beside it starts a resource.
363 364 365 366 367 |
# File 'lib/mcp_client/schema_validator/references.rb', line 363 def resource_root?(schema, dialect) return false if dialect == DRAFT_07 && schema.key?('$ref') resource_start?(schema) end |
#resource_start?(schema) ⇒ Boolean
Whether a schema object starts a new schema resource: its $id is a
URI rather than a bare fragment (a draft-07 $id: "#name" is a
plain-name identifier, not a base).
352 353 354 355 |
# File 'lib/mcp_client/schema_validator/references.rb', line 352 def resource_start?(schema) id = schema['$id'] id.is_a?(String) && !id.empty? && !id.start_with?('#') end |
#retarget_reference(index, resource, ref) ⇒ Array(Hash, String), Array(nil, nil)
The bundled resource a reference outside the fragment space names, with the bare fragment left to resolve inside it.
49 50 51 52 53 54 |
# File 'lib/mcp_client/schema_validator/references.rb', line 49 def retarget_reference(index, resource, ref) uri, fragment = ref.split('#', 2) base = merge_uri(index[:bases][resource] || '', uri.to_s) target = base && index[:by_base][base] target ? [target, "##{fragment}"] : [nil, nil] end |