Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion lib/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,13 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = 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
Expand Down Expand Up @@ -155,7 +161,11 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = 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)
Expand Down
7 changes: 3 additions & 4 deletions lib/dereference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = 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;
Expand Down Expand Up @@ -123,11 +123,10 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = 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)
Expand Down
46 changes: 32 additions & 14 deletions lib/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -125,6 +127,31 @@ export interface DereferenceOptions {
cloneReferences?: boolean;
}

export type ResolveOptions<S extends object = JSONSchema> = {
/**
* 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<ResolverOptions<S>> | boolean;
http?: HTTPResolverOptions<S> | boolean;
} & {
[key: string]:
| Partial<ResolverOptions<S>>
| HTTPResolverOptions<S>
| boolean
| ((path: string, value?: unknown) => boolean)
| undefined;
};

/**
* Options that determine how JSON schemas are parsed, resolved, and dereferenced.
*
Expand All @@ -150,16 +177,7 @@ export interface $RefParserOptions<S extends object = JSONSchema> {
*
* 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<ResolverOptions<S>> | boolean;
http?: HTTPResolverOptions<S> | boolean;
} & {
[key: string]: Partial<ResolverOptions<S>> | HTTPResolverOptions<S> | boolean | undefined;
};
resolve: ResolveOptions<S>;

/**
* By default, JSON Schema $Ref Parser throws the first error it encounters. Setting `continueOnError` to `true`
Expand Down
6 changes: 4 additions & 2 deletions lib/resolve-external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -77,8 +77,10 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
) {
seen ||= new Set();
let promises: any = [];
const resolveOptions = (options.resolve || {}) as ResolveOptions<S>;
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)) {
Expand Down
6 changes: 3 additions & 3 deletions test/specs/ref-in-excluded-path/dereferenced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default {
parameters: {
a: {
example: {
$ref: "#/literal-param-component-example",
$ref: "./literal-param-component-example-does-not-exist.yaml",
},
},
b: {
Expand Down Expand Up @@ -53,7 +53,7 @@ export default {
},
{
example: {
$ref: "#/literal-q1",
$ref: "./literal-q1-does-not-exist.yaml",
},
in: "query",
name: "q1",
Expand Down Expand Up @@ -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",
},
},
},
Expand Down
92 changes: 88 additions & 4 deletions test/specs/ref-in-excluded-path/ref-in-excluded-path.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
6 changes: 3 additions & 3 deletions test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down