@amritk/resolve-refs
Advanced tools
| import type { JsonPath } from './types.js'; | ||
| /** | ||
| * The reference keywords JSON Schema uses to point at another schema. `$ref` | ||
| * (all drafts) is a static pointer; `$dynamicRef` (2020-12) and `$recursiveRef` | ||
| * (2019-09) late-bind to an anchor so a recursive/extensible schema can refer to | ||
| * itself. We inline all three; the dynamic forms bind to their document-global | ||
| * anchor (see {@link resolveFragment}). | ||
| */ | ||
| export type RefKeyword = '$ref' | '$dynamicRef' | '$recursiveRef'; | ||
| /** A reference carried by an object: which keyword, and its string value. */ | ||
| export type Reference = { | ||
| keyword: RefKeyword; | ||
| value: string; | ||
| }; | ||
| /** Returns the reference keyword `obj` carries (if any), preferring `$ref`. */ | ||
| export declare const readReference: (obj: Record<string, unknown>) => Reference | undefined; | ||
| /** The resolved target of a reference: the node and the path to it within its document. */ | ||
| export type ResolvedTarget = { | ||
| value: unknown; | ||
| pointer: JsonPath; | ||
| }; | ||
| /** | ||
| * Resolves a reference `fragment` (the part after `#`) within `root`, per its | ||
| * `keyword`. Returns the target node and its in-document path, or `undefined` | ||
| * when nothing matches. | ||
| * | ||
| * - **JSON Pointer** (`''`, `/a/b`) — a plain pointer lookup, for any keyword. | ||
| * - **`$anchor` name** (`node`) — searches for a `$anchor`/`$dynamicAnchor` equal | ||
| * to the name. A `$dynamicRef` prefers a `$dynamicAnchor`, then falls back to a | ||
| * plain `$anchor`, so it degrades to `$ref` semantics when nothing dynamic | ||
| * matches (2020-12). | ||
| * - **`$recursiveRef`** (always `#`) — binds to the object carrying | ||
| * `$recursiveAnchor: true`, falling back to the document root when there is | ||
| * none (2019-09). | ||
| * | ||
| * Anchor search is document-global: we bind to the single matching anchor in | ||
| * `root` rather than walking the dynamic scope. For one bundled document — what a | ||
| * linter dereferences — there is exactly one anchor per name, so this agrees with | ||
| * the full dynamic-scope algorithm. Nested `$id` base-URI re-scoping is not | ||
| * modelled (the common bundled case does not rely on it). | ||
| */ | ||
| export declare const resolveFragment: (root: unknown, keyword: RefKeyword, fragment: string) => ResolvedTarget | undefined; |
| import { getByPointer, pointerToPath } from './get-by-pointer.js'; | ||
| // `$ref` is listed first so that a node carrying several reference keywords | ||
| // resolves through the static one, matching how validators treat `$ref`. | ||
| const REF_KEYWORDS = ['$ref', '$dynamicRef', '$recursiveRef']; | ||
| /** Returns the reference keyword `obj` carries (if any), preferring `$ref`. */ | ||
| export const readReference = (obj) => { | ||
| for (const keyword of REF_KEYWORDS) { | ||
| const value = obj[keyword]; | ||
| if (typeof value === 'string') | ||
| return { keyword, value }; | ||
| } | ||
| return undefined; | ||
| }; | ||
| /** A fragment is a JSON Pointer when it is empty or begins with `/`; otherwise a plain-name anchor. */ | ||
| const isPointerFragment = (fragment) => fragment === '' || fragment.startsWith('/'); | ||
| /** | ||
| * Depth-first search for the first object in `root` satisfying `predicate`, | ||
| * returning it with the path to it. `seen` guards against cyclic inputs. Used to | ||
| * locate `$anchor`/`$dynamicAnchor`/`$recursiveAnchor` targets. | ||
| */ | ||
| const search = (root, predicate) => { | ||
| const seen = new Set(); | ||
| const walk = (node, pointer) => { | ||
| if (node === null || typeof node !== 'object' || seen.has(node)) | ||
| return undefined; | ||
| seen.add(node); | ||
| if (!Array.isArray(node) && predicate(node)) | ||
| return { value: node, pointer }; | ||
| if (Array.isArray(node)) { | ||
| for (let i = 0; i < node.length; i++) { | ||
| const found = walk(node[i], [...pointer, i]); | ||
| if (found) | ||
| return found; | ||
| } | ||
| } | ||
| else { | ||
| for (const key of Object.keys(node)) { | ||
| const found = walk(node[key], [...pointer, key]); | ||
| if (found) | ||
| return found; | ||
| } | ||
| } | ||
| return undefined; | ||
| }; | ||
| return walk(root, []); | ||
| }; | ||
| /** | ||
| * Resolves a reference `fragment` (the part after `#`) within `root`, per its | ||
| * `keyword`. Returns the target node and its in-document path, or `undefined` | ||
| * when nothing matches. | ||
| * | ||
| * - **JSON Pointer** (`''`, `/a/b`) — a plain pointer lookup, for any keyword. | ||
| * - **`$anchor` name** (`node`) — searches for a `$anchor`/`$dynamicAnchor` equal | ||
| * to the name. A `$dynamicRef` prefers a `$dynamicAnchor`, then falls back to a | ||
| * plain `$anchor`, so it degrades to `$ref` semantics when nothing dynamic | ||
| * matches (2020-12). | ||
| * - **`$recursiveRef`** (always `#`) — binds to the object carrying | ||
| * `$recursiveAnchor: true`, falling back to the document root when there is | ||
| * none (2019-09). | ||
| * | ||
| * Anchor search is document-global: we bind to the single matching anchor in | ||
| * `root` rather than walking the dynamic scope. For one bundled document — what a | ||
| * linter dereferences — there is exactly one anchor per name, so this agrees with | ||
| * the full dynamic-scope algorithm. Nested `$id` base-URI re-scoping is not | ||
| * modelled (the common bundled case does not rely on it). | ||
| */ | ||
| export const resolveFragment = (root, keyword, fragment) => { | ||
| if (keyword === '$recursiveRef') { | ||
| const anchored = search(root, (obj) => obj['$recursiveAnchor'] === true); | ||
| return anchored ?? { value: root, pointer: [] }; | ||
| } | ||
| if (isPointerFragment(fragment)) { | ||
| const value = getByPointer(root, fragment); | ||
| return value === undefined ? undefined : { value, pointer: pointerToPath(fragment) }; | ||
| } | ||
| if (keyword === '$dynamicRef') { | ||
| return (search(root, (obj) => obj['$dynamicAnchor'] === fragment) ?? search(root, (obj) => obj['$anchor'] === fragment)); | ||
| } | ||
| return search(root, (obj) => obj['$anchor'] === fragment || obj['$dynamicAnchor'] === fragment); | ||
| }; |
| import { readFileSync } from 'node:fs'; | ||
| import { dirname, resolve as resolvePath } from 'node:path'; | ||
| import { getByPointer, pointerToPath } from './get-by-pointer.js'; | ||
| import { isPrivateHost } from './is-private-host.js'; | ||
| import { readReference, resolveFragment } from './reference.js'; | ||
| import { assignKey } from './safe-assign.js'; | ||
@@ -24,3 +24,3 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
| }; | ||
| /** Splits a `$ref` into its document part and JSON-pointer part. */ | ||
| /** Splits a `$ref` into its document part and its fragment (pointer or anchor name). */ | ||
| const splitRef = (ref) => { | ||
@@ -30,3 +30,3 @@ const hashIdx = ref.indexOf('#'); | ||
| filePart: hashIdx === -1 ? ref : ref.slice(0, hashIdx), | ||
| pointer: hashIdx === -1 ? '' : ref.slice(hashIdx + 1), | ||
| fragment: hashIdx === -1 ? '' : ref.slice(hashIdx + 1), | ||
| }; | ||
@@ -209,11 +209,12 @@ }; | ||
| const obj = node; | ||
| if (typeof obj['$ref'] === 'string') { | ||
| const { filePart } = splitRef(obj['$ref']); | ||
| const reference = readReference(obj); | ||
| if (reference) { | ||
| const { filePart } = splitRef(reference.value); | ||
| if (filePart !== '') | ||
| out.add(filePart); | ||
| } | ||
| // Recurse into every key — including a `$ref` node's siblings, which apply | ||
| // Recurse into every key — including a reference node's siblings, which apply | ||
| // alongside the referenced schema (2020-12) and may carry their own refs. | ||
| for (const key of Object.keys(obj)) { | ||
| if (key === '$ref') | ||
| if (reference && key === reference.keyword) | ||
| continue; | ||
@@ -261,30 +262,44 @@ collectRefTargets(obj[key], out); | ||
| const obj = node; | ||
| if (typeof obj['$ref'] === 'string') { | ||
| const { filePart, pointer } = splitRef(obj['$ref']); | ||
| const reference = readReference(obj); | ||
| if (reference) { | ||
| const { keyword, value } = reference; | ||
| const { filePart, fragment } = splitRef(value); | ||
| const targetLocation = filePart === '' ? baseLocation : joinLocation(baseLocation, filePart); | ||
| const targetRoot = docCache.get(targetLocation) ?? {}; | ||
| const cacheKey = `${targetLocation}#${pointer}`; | ||
| // Cache/cycle key includes the keyword: `$ref #x` and `$dynamicRef #x` can | ||
| // bind to different targets, so they must not share a cache slot. | ||
| const cacheKey = `${keyword} ${targetLocation}#${fragment}`; | ||
| let resolved; | ||
| if (refCache.has(cacheKey)) { | ||
| const cached = refCache.get(cacheKey); | ||
| resolved = cached === CYCLE ? {} : cached; | ||
| let pointer; | ||
| const cached = refCache.get(cacheKey); | ||
| if (cached === CYCLE) { | ||
| resolved = {}; | ||
| pointer = []; | ||
| } | ||
| else if (cached !== undefined) { | ||
| resolved = cached.value; | ||
| pointer = cached.pointer; | ||
| } | ||
| else { | ||
| refCache.set(cacheKey, CYCLE); | ||
| const target = pointer ? getByPointer(targetRoot, pointer) : targetRoot; | ||
| resolved = resolveAt(target, targetLocation, docCache, refCache, origins); | ||
| refCache.set(cacheKey, resolved); | ||
| // `$anchor`/`$dynamicAnchor`/`$recursiveAnchor` are resolved within the | ||
| // target document; a plain pointer is a direct lookup. A fragment that | ||
| // resolves to nothing inlines as `undefined` (kept as-is for parity). | ||
| const found = resolveFragment(targetRoot, keyword, fragment); | ||
| pointer = found?.pointer ?? []; | ||
| resolved = resolveAt(found?.value, targetLocation, docCache, refCache, origins); | ||
| refCache.set(cacheKey, { value: resolved, pointer }); | ||
| // Stamp the inlined node with where it was defined so a consumer can map a | ||
| // resolved-tree node back to its source document/path. Only objects/arrays are | ||
| // stamped (primitives can't key the map). First-write-wins: resolution recurses | ||
| // to the deepest `$ref` before returning, so the *definition* site stamps first; | ||
| // to the deepest ref before returning, so the *definition* site stamps first; | ||
| // an outer ref that merely points (transitively) at the same object must not | ||
| // overwrite it with an intermediate location. | ||
| if (origins && resolved !== null && typeof resolved === 'object' && !origins.has(resolved)) { | ||
| origins.set(resolved, { location: targetLocation, pointer: pointerToPath(pointer) }); | ||
| origins.set(resolved, { location: targetLocation, pointer }); | ||
| } | ||
| } | ||
| // Keywords sibling to `$ref` apply alongside the referenced schema (2020-12), | ||
| // so preserve them by combining both in an `allOf` rather than dropping them. | ||
| const siblingKeys = Object.keys(obj).filter((key) => key !== '$ref'); | ||
| // Keywords sibling to a reference apply alongside the referenced schema | ||
| // (2020-12), so preserve them by combining both in an `allOf`. | ||
| const siblingKeys = Object.keys(obj).filter((key) => key !== keyword); | ||
| if (siblingKeys.length === 0) | ||
@@ -297,5 +312,5 @@ return resolved; | ||
| const merged = { ...siblings, allOf: [...existingAllOf, resolved] }; | ||
| // Stamp the wrapper too, so origin lookups resolve for a `$ref`-with-siblings node. | ||
| // Stamp the wrapper too, so origin lookups resolve for a ref-with-siblings node. | ||
| if (origins && !origins.has(merged)) | ||
| origins.set(merged, { location: targetLocation, pointer: pointerToPath(pointer) }); | ||
| origins.set(merged, { location: targetLocation, pointer }); | ||
| return merged; | ||
@@ -302,0 +317,0 @@ } |
+39
-19
@@ -1,2 +0,3 @@ | ||
| import { getByPointer, pointerToPath } from './get-by-pointer.js'; | ||
| import { pointerToPath } from './get-by-pointer.js'; | ||
| import { readReference, resolveFragment } from './reference.js'; | ||
| import { assignKey } from './safe-assign.js'; | ||
@@ -6,2 +7,4 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
| const CYCLE = Symbol('cycle'); | ||
| // A ref that resolved to nothing; cached so a repeated bad ref reports once. | ||
| const MISSING = Symbol('missing'); | ||
| /** | ||
@@ -20,24 +23,41 @@ * Single-pass internal-only `$ref` resolver. Each unique ref string is resolved | ||
| const obj = node; | ||
| if (typeof obj['$ref'] === 'string') { | ||
| const ref = obj['$ref']; | ||
| const reference = readReference(obj); | ||
| if (reference) { | ||
| const { keyword, value: ref } = reference; | ||
| if (!ref.startsWith('#')) | ||
| return obj; // external ref — leave as-is | ||
| // Cache/cycle key includes the keyword: `$ref #x` and `$dynamicRef #x` can | ||
| // bind to different targets, so they must not share a cache slot. | ||
| const cacheKey = `${keyword} ${ref}`; | ||
| let target; | ||
| if (cache.has(ref)) { | ||
| const cached = cache.get(ref); | ||
| target = cached === CYCLE ? {} : cached; | ||
| let pointer; | ||
| const cached = cache.get(cacheKey); | ||
| if (cached === MISSING) | ||
| return obj; | ||
| if (cached === CYCLE) { | ||
| target = {}; | ||
| pointer = []; | ||
| } | ||
| else if (cached !== undefined) { | ||
| target = cached.target; | ||
| pointer = cached.pointer; | ||
| } | ||
| else { | ||
| cache.set(ref, CYCLE); | ||
| const pointed = getByPointer(root, ref.slice(1)); | ||
| if (pointed === undefined) { | ||
| // An internal pointer that resolves to nothing (a typo'd or missing | ||
| // definition) was previously inlined as literal `undefined` with no trace. | ||
| // Record it and keep the original `$ref` node so the failure is visible. | ||
| errors.push({ message: `Cannot resolve internal $ref "${ref}"`, path: pointerToPath(ref.slice(1)) }); | ||
| cache.set(ref, obj); | ||
| cache.set(cacheKey, CYCLE); | ||
| const found = resolveFragment(root, keyword, ref.slice(1)); | ||
| if (found === undefined) { | ||
| // A reference that resolves to nothing (a typo'd pointer or a missing | ||
| // anchor) was previously inlined as literal `undefined` with no trace. | ||
| // Record it and keep the original node so the failure is visible. | ||
| const fragment = ref.slice(1); | ||
| errors.push({ | ||
| message: `Cannot resolve internal ${keyword} "${ref}"`, | ||
| path: fragment === '' || fragment.startsWith('/') ? pointerToPath(fragment) : [], | ||
| }); | ||
| cache.set(cacheKey, MISSING); | ||
| return obj; | ||
| } | ||
| target = resolveInternal(pointed, root, cache, origins, errors); | ||
| cache.set(ref, target); | ||
| pointer = found.pointer; | ||
| target = resolveInternal(found.value, root, cache, origins, errors); | ||
| cache.set(cacheKey, { target, pointer }); | ||
| // Stamp the inlined node with the path it was defined at (see resolveAt). | ||
@@ -47,3 +67,3 @@ // First-write-wins so the deepest definition stamps before any outer ref that | ||
| if (origins && target !== null && typeof target === 'object' && !origins.has(target)) { | ||
| origins.set(target, { location: '', pointer: pointerToPath(ref.slice(1)) }); | ||
| origins.set(target, { location: '', pointer }); | ||
| } | ||
@@ -56,3 +76,3 @@ } | ||
| // sibling-free target so each occurrence applies its own siblings. | ||
| const siblingKeys = Object.keys(obj).filter((key) => key !== '$ref'); | ||
| const siblingKeys = Object.keys(obj).filter((key) => key !== keyword); | ||
| if (siblingKeys.length === 0) | ||
@@ -68,3 +88,3 @@ return target; | ||
| if (origins && !origins.has(merged)) | ||
| origins.set(merged, { location: '', pointer: pointerToPath(ref.slice(1)) }); | ||
| origins.set(merged, { location: '', pointer }); | ||
| return merged; | ||
@@ -71,0 +91,0 @@ } |
+1
-1
| { | ||
| "name": "@amritk/resolve-refs", | ||
| "version": "0.2.3", | ||
| "version": "0.3.0", | ||
| "description": "Resolve and inline JSON Schema / OpenAPI $refs — internal, cross-file, and remote — with session caching and a default-deny SSRF guard.", | ||
@@ -5,0 +5,0 @@ "module": "./dist/index.js", |
+5
-0
@@ -8,2 +8,7 @@ # @amritk/resolve-refs | ||
| an empty object so the result is always finite. | ||
| - **Anchors + dynamic refs.** Beyond JSON-pointer `$ref`s, plain-name `$anchor` | ||
| references (`#node`), `$dynamicRef`/`$dynamicAnchor` (2020-12), and | ||
| `$recursiveRef`/`$recursiveAnchor` (2019-09) are dereferenced too. The dynamic | ||
| forms bind to their document-global anchor — the single-bundle case; nested | ||
| `$id` base-URI re-scoping is not modelled. | ||
| - **Cross-file + remote.** Relative refs resolve against the document they appear | ||
@@ -10,0 +15,0 @@ in (a ref inside a remote doc stays remote, one inside a local file stays |
44348
20.96%18
12.5%885
21.57%54
10.2%