diff --git a/lib/bundle.ts b/lib/bundle.ts index be47366d..ee2dbc07 100644 --- a/lib/bundle.ts +++ b/lib/bundle.ts @@ -110,7 +110,13 @@ function crawl = Parse const bundleOptions = (options.bundle || {}) as BundleOptions; const isExcludedPath = bundleOptions.excludedPathMatcher || (() => false); - if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot) && !seen.has(obj)) { + if ( + obj && + typeof obj === "object" && + !ArrayBuffer.isView(obj) && + !isExcludedPath(pathFromRoot, obj) && + !seen.has(obj) + ) { // Input schemas are normally JSON trees, but callers can pass pre-circular // JavaScript objects. Tracking identities keeps those cycles intact without // recursively walking them until the call stack overflows. It also avoids @@ -155,7 +161,11 @@ function crawl = Parse for (const key of keys) { const keyPath = Pointer.join(path, key); const keyPathFromRoot = Pointer.join(pathFromRoot, key); + const value = obj[key]; + if (isExcludedPath(keyPathFromRoot, value)) { + continue; + } const childLegacyIdScope = getSchemaIdMode(value, legacyIdScope); const childScopeBase = dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value) diff --git a/lib/dereference.ts b/lib/dereference.ts index fb651f4b..5c71afb4 100644 --- a/lib/dereference.ts +++ b/lib/dereference.ts @@ -94,7 +94,7 @@ function crawl = Parse const isExcludedPath = derefOptions.excludedPathMatcher || (() => false); if (derefOptions?.circular === "ignore" || !processedObjects.has(obj)) { - if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) { + if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot, obj)) { parents.add(obj); processedObjects.add(obj); const currentScopeBase = scopeBase; @@ -123,11 +123,10 @@ function crawl = Parse const keyPath = Pointer.join(path, key); const keyPathFromRoot = Pointer.join(pathFromRoot, key); - if (isExcludedPath(keyPathFromRoot)) { + const value = obj[key]; + if (isExcludedPath(keyPathFromRoot, value)) { continue; } - - const value = obj[key]; const childLegacyIdScope = getSchemaIdMode(value, legacyIdScope); const childScopeBase = dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value) diff --git a/lib/options.ts b/lib/options.ts index 5039afaa..90548d7e 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -17,9 +17,10 @@ export interface BundleOptions { /** * A function, called for each path, which can return true to stop this path and all * subpaths from being processed further. This is useful in schemas where some - * subpaths contain literal $ref keys that should not be changed. + * subpaths contain literal $ref keys that should not be changed. The value at the + * current path is supplied so callers can distinguish references from containers. */ - excludedPathMatcher?(path: string): boolean; + excludedPathMatcher?(path: string, value?: unknown): boolean; /** * Callback invoked during bundling. @@ -54,9 +55,10 @@ export interface DereferenceOptions { /** * A function, called for each path, which can return true to stop this path and all * subpaths from being dereferenced further. This is useful in schemas where some - * subpaths contain literal $ref keys that should not be dereferenced. + * subpaths contain literal $ref keys that should not be dereferenced. The value at + * the current path is supplied so callers can distinguish references from containers. */ - excludedPathMatcher?(path: string): boolean; + excludedPathMatcher?(path: string, value?: unknown): boolean; /** * Callback invoked during circular reference detection. @@ -125,6 +127,31 @@ export interface DereferenceOptions { cloneReferences?: boolean; } +export type ResolveOptions = { + /** + * Determines whether external $ref pointers will be resolved. If this option is disabled, then external `$ref` pointers will simply be ignored. + */ + external?: boolean; + + /** + * A function, called for each path, which can return true to stop this path and all + * subpaths from being resolved further. This is useful in schemas where some subpaths + * contain literal external $ref keys that should not be downloaded. The value at the + * current path is supplied so callers can distinguish references from containers. + */ + excludedPathMatcher?(path: string, value?: unknown): boolean; + + file?: Partial> | boolean; + http?: HTTPResolverOptions | boolean; +} & { + [key: string]: + | Partial> + | HTTPResolverOptions + | boolean + | ((path: string, value?: unknown) => boolean) + | undefined; +}; + /** * Options that determine how JSON schemas are parsed, resolved, and dereferenced. * @@ -150,16 +177,7 @@ export interface $RefParserOptions { * * JSON Schema `$Ref` Parser comes with built-in support for HTTP and HTTPS, as well as support for local files (when running in Node.js). You can configure or disable either of these built-in resolvers. You can also add your own custom resolvers if you want. */ - resolve: { - /** - * Determines whether external $ref pointers will be resolved. If this option is disabled, then external `$ref` pointers will simply be ignored. - */ - external?: boolean; - file?: Partial> | boolean; - http?: HTTPResolverOptions | boolean; - } & { - [key: string]: Partial> | HTTPResolverOptions | boolean | undefined; - }; + resolve: ResolveOptions; /** * By default, JSON Schema $Ref Parser throws the first error it encounters. Setting `continueOnError` to `true` diff --git a/lib/resolve-external.ts b/lib/resolve-external.ts index c2e6908f..bf449dd2 100644 --- a/lib/resolve-external.ts +++ b/lib/resolve-external.ts @@ -5,7 +5,7 @@ import * as url from "./util/url.js"; import { isHandledError } from "./util/errors.js"; import { getSchemaBasePath, getSchemaIdMode } from "./util/schema-resources.js"; import type $Refs from "./refs.js"; -import type { ParserOptions } from "./options.js"; +import type { ParserOptions, ResolveOptions } from "./options.js"; import type { JSONSchema } from "./types/index.js"; import type $RefParser from "./index.js"; @@ -77,8 +77,10 @@ function crawl = Parse ) { seen ||= new Set(); let promises: any = []; + const resolveOptions = (options.resolve || {}) as ResolveOptions; + const isExcludedPath = resolveOptions.excludedPathMatcher || (() => false); - if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !seen.has(obj)) { + if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(path, obj) && !seen.has(obj)) { seen.add(obj); // Track previously seen objects to avoid infinite recursion const currentScopeBase = scopeBase; if ($Ref.isExternal$Ref(obj)) { diff --git a/test/specs/ref-in-excluded-path/dereferenced.ts b/test/specs/ref-in-excluded-path/dereferenced.ts index c9bfccab..0727b323 100644 --- a/test/specs/ref-in-excluded-path/dereferenced.ts +++ b/test/specs/ref-in-excluded-path/dereferenced.ts @@ -18,7 +18,7 @@ export default { parameters: { a: { example: { - $ref: "#/literal-param-component-example", + $ref: "./literal-param-component-example-does-not-exist.yaml", }, }, b: { @@ -53,7 +53,7 @@ export default { }, { example: { - $ref: "#/literal-q1", + $ref: "./literal-q1-does-not-exist.yaml", }, in: "query", name: "q1", @@ -97,7 +97,7 @@ export default { content: { "application/json": { example: { - $ref: "#/literal-example", + $ref: "https://example.com/literal-example-that-should-not-be-downloaded.json", }, }, }, diff --git a/test/specs/ref-in-excluded-path/ref-in-excluded-path.spec.ts b/test/specs/ref-in-excluded-path/ref-in-excluded-path.spec.ts index 1ba430de..7a6b4886 100644 --- a/test/specs/ref-in-excluded-path/ref-in-excluded-path.spec.ts +++ b/test/specs/ref-in-excluded-path/ref-in-excluded-path.spec.ts @@ -6,17 +6,101 @@ import dereferencedSchema from "./dereferenced.js"; import { expect } from "vitest"; describe("Schema with literal $refs in examples", () => { - it("should exclude the given paths from dereferencing", async () => { + const excludedPathMatcher = (schemaPath: string) => { + return /\/example(\/|$|s\/[^/]+\/value(\/|$))/.test(schemaPath); + }; + + it("should exclude the given paths from resolving and dereferencing", async () => { const parser = new $RefParser(); const schema = await parser.dereference(path.rel("test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml"), { + resolve: { + excludedPathMatcher, + }, dereference: { - excludedPathMatcher: (schemaPath: any) => { - return /\/example(\/|$|s\/[^/]+\/value(\/|$))/.test(schemaPath); - }, + excludedPathMatcher, }, }); expect(schema).to.equal(parser.schema); expect(schema).to.deep.equal(dereferencedSchema); }); + + it("should exclude the given paths from resolving and bundling", async () => { + const parser = new $RefParser(); + const schemaPath = path.rel("test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml"); + const parsedSchema = await $RefParser.parse(schemaPath); + + const schema = await parser.bundle(schemaPath, { + resolve: { + excludedPathMatcher, + }, + bundle: { + excludedPathMatcher, + }, + }); + + expect(schema).to.equal(parser.schema); + expect(schema).to.deep.equal(parsedSchema); + }); + + it("should supply the path value so callers can distinguish references", async () => { + const matcher = (schemaPath: string, value?: unknown) => { + return ( + schemaPath.includes("/example/") && + typeof value === "object" && + value !== null && + "$ref" in value && + typeof value.$ref === "string" && + !value.$ref.startsWith("#") + ); + }; + const inputSchema = { + definitions: { + user: { + type: "object", + properties: { + id: { type: "string" }, + }, + }, + }, + example: { + internal: { $ref: "#/definitions/user" }, + manager: { + $ref: "https://gateway.example.com/scim/v2/Users/789012", + value: "789012", + displayName: "Jane Manager", + }, + }, + }; + const expectedSchema = { + definitions: { + user: { + type: "object", + properties: { + id: { type: "string" }, + }, + }, + }, + example: { + internal: { + type: "object", + properties: { + id: { type: "string" }, + }, + }, + manager: { + $ref: "https://gateway.example.com/scim/v2/Users/789012", + value: "789012", + displayName: "Jane Manager", + }, + }, + }; + + const schema = await $RefParser.dereference(inputSchema, { + resolve: { excludedPathMatcher: matcher }, + dereference: { excludedPathMatcher: matcher }, + }); + + expect(schema).to.deep.equal(expectedSchema); + }); }); diff --git a/test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml b/test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml index 284e44d9..f178c635 100644 --- a/test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml +++ b/test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml @@ -14,7 +14,7 @@ paths: - name: q1 in: query example: - $ref: "#/literal-q1" + $ref: "./literal-q1-does-not-exist.yaml" - name: q2 in: query examples: @@ -37,7 +37,7 @@ paths: content: application/json: example: - $ref: "#/literal-example" + $ref: "https://example.com/literal-example-that-should-not-be-downloaded.json" components: examples: query-example: @@ -51,7 +51,7 @@ components: parameters: a: example: - $ref: "#/literal-param-component-example" + $ref: "./literal-param-component-example-does-not-exist.yaml" b: examples: example1: