Skip to content

fix: allow $defs and fix circular pointers issue with $ref in root #383

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 12, 2025
Merged
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
2 changes: 1 addition & 1 deletion docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ JSON Schema $Ref Parser comes with built-in support for HTTP and HTTPS, as well
| `file.order` `http.order` | `number` | Resolvers run in a specific order, relative to other resolvers. For example, a resolver with `order: 5` will run _before_ a resolver with `order: 10`. If a resolver is unable to successfully resolve a path, then the next resolver is tried, until one succeeds or they all fail.<br><br>You can change the order in which resolvers run, which is useful if you know that most of your file references will be a certain type, or if you add [your own custom resolver](plugins/resolvers.md) that you want to run _first_. |
| `file.canRead` `http.canRead` | `boolean`, `RegExp`, `string`, `array`, `function` | Determines which resolvers will be used for which files.<br><br>A regular expression can be used to match files by their full path. A string (or array of strings) can be used to match files by their file extension. Or a function can be used to perform more complex matching logic. See the [custom resolver](plugins/resolvers.md) docs for details. |
| `http.headers` | `object` | You can specify any HTTP headers that should be sent when downloading files. For example, some servers may require you to set the `Accept` or `Referrer` header. |
| `http.timeout` | `number` | The amount of time (in milliseconds) to wait for a response from the server when downloading files. The default is 60 seconds. |
| `http.timeout` | `number` | The amount of time (in milliseconds) to wait for a response from the server when downloading files. The default is 60 seconds. |
| `http.redirects` | `number` | The maximum number of HTTP redirects to follow per file. The default is 5. To disable automatic following of redirects, set this to zero. |
| `http.withCredentials` | `boolean` | Set this to `true` if you're downloading files from a CORS-enabled server that requires authentication |

Expand Down
14 changes: 10 additions & 4 deletions lib/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
const keys = Object.keys(obj).sort((a, b) => {
// Most people will expect references to be bundled into the the "definitions" property,
// so we always crawl that property first, if it exists.
if (a === "definitions") {
if (a === "definitions" || a === "$defs") {
return -1;
} else if (b === "definitions") {
} else if (b === "definitions" || b === "$defs") {
return 1;
} else {
// Otherwise, crawl the keys based on their length.
Expand Down Expand Up @@ -216,8 +216,14 @@ function remap(inventory: InventoryEntry[]) {
} else {
// Determine how far each $ref is from the "definitions" property.
// Most people will expect references to be bundled into the the "definitions" property if possible.
const aDefinitionsIndex = a.pathFromRoot.lastIndexOf("/definitions");
const bDefinitionsIndex = b.pathFromRoot.lastIndexOf("/definitions");
const aDefinitionsIndex = Math.max(
a.pathFromRoot.lastIndexOf("/definitions"),
a.pathFromRoot.lastIndexOf("/$defs"),
);
const bDefinitionsIndex = Math.max(
b.pathFromRoot.lastIndexOf("/definitions"),
b.pathFromRoot.lastIndexOf("/$defs"),
);

if (aDefinitionsIndex !== bDefinitionsIndex) {
// Give higher priority to the $ref that's closer to the "definitions" property
Expand Down
2 changes: 1 addition & 1 deletion lib/dereference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
//
// This check is not perfect and the design of the dereference caching mechanism needs a total
// overhaul.
if (typeof cache.value === 'object' && '$ref' in cache.value && '$ref' in $ref) {
if (typeof cache.value === "object" && "$ref" in cache.value && "$ref" in $ref) {
if (cache.value.$ref === $ref.$ref) {
return cache;
} else {
Expand Down
7 changes: 1 addition & 6 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,7 @@ export class $RefParser<S extends object = JSONSchema, O extends ParserOptions<S
public resolve(schema: S | string | unknown, options: O): Promise<$Refs<S, O>>;
public resolve(schema: S | string | unknown, options: O, callback: $RefsCallback<S, O>): Promise<void>;
public resolve(path: string, schema: S | string | unknown, options: O): Promise<$Refs<S, O>>;
public resolve(
path: string,
schema: S | string | unknown,
options: O,
callback: $RefsCallback<S, O>,
): Promise<void>;
public resolve(path: string, schema: S | string | unknown, options: O, callback: $RefsCallback<S, O>): Promise<void>;
async resolve() {
const args = normalizeArgs<S, O>(arguments);

Expand Down
6 changes: 1 addition & 5 deletions lib/pointer.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 { JSONParserError, InvalidPointerError, MissingPointerError, isHandledError } from "./util/errors.js";
import type { JSONSchema } from "./types";

export const nullSymbol = Symbol('null');
export const nullSymbol = Symbol("null");

const slashes = /\//g;
const tildes = /~/g;
Expand Down Expand Up @@ -101,10 +101,6 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
this.path = Pointer.join(this.path, tokens.slice(i));
}

if (typeof this.value === "object" && this.value !== null && !isRootPath(pathFromRoot) && "$ref" in this.value) {
return this;
}

const token = tokens[i];

if (this.value[token] === undefined || (this.value[token] === null && i === tokens.length - 1)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
$ref: ./circular-external-direct-child.yaml#/foo
foo:
type: object
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ describe("Schema with direct circular (recursive) external $refs", () => {
path.rel("test/specs/circular-external-direct/circular-external-direct-root.yaml"),
);
expect(schema).to.equal(parser.schema);
expect(schema).to.deep.equal(parsedSchema.schema);
expect(parser.$refs.paths()).to.deep.equal([
path.abs("test/specs/circular-external-direct/circular-external-direct-root.yaml"),
]);
Expand All @@ -29,6 +28,6 @@ describe("Schema with direct circular (recursive) external $refs", () => {
expect(schema).to.equal(parser.schema);
expect(schema).to.deep.equal(dereferencedSchema);
// The "circular" flag should be set
expect(parser.$refs.circular).to.equal(true);
expect(parser.$refs.circular).to.equal(false);
});
});
5 changes: 4 additions & 1 deletion test/specs/circular-external-direct/dereferenced.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export default {
$ref: "./circular-external-direct-root.yaml#/foo",
foo: {
type: "object",
},
type: "object",
};
4 changes: 3 additions & 1 deletion test/specs/circular-external-direct/parsed.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export default {
schema: {
$ref: "./circular-external-direct-child.yaml#/foo",
foo: {
type: "object",
},
},
};
12 changes: 6 additions & 6 deletions test/specs/circular-external/circular-external.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,22 @@ describe("Schema with circular (recursive) external $refs", () => {
const parser = new $RefParser();

const schema = await parser.dereference(path.rel("test/specs/circular-external/circular-external.yaml"), {
dereference: { circular: 'ignore' },
dereference: { circular: "ignore" },
});

expect(schema).to.equal(parser.schema);
expect(schema).not.to.deep.equal(dereferencedSchema);
expect(parser.$refs.circular).to.equal(true);
// @ts-expect-error TS(2532): Object is possibly 'undefined'.
expect(schema.definitions.pet.title).to.equal('pet');
expect(schema.definitions.pet.title).to.equal("pet");
// @ts-expect-error TS(2532): Object is possibly 'undefined'.
expect(schema.definitions.thing).to.deep.equal({ $ref: 'circular-external.yaml#/definitions/thing'});
expect(schema.definitions.thing).to.deep.equal({ $ref: "circular-external.yaml#/definitions/thing" });
// @ts-expect-error TS(2532): Object is possibly 'undefined'.
expect(schema.definitions.person).to.deep.equal({ $ref: 'definitions/person.yaml'});
expect(schema.definitions.person).to.deep.equal({ $ref: "definitions/person.yaml" });
// @ts-expect-error TS(2532): Object is possibly 'undefined'.
expect(schema.definitions.parent).to.deep.equal({ $ref: 'definitions/parent.yaml'});
expect(schema.definitions.parent).to.deep.equal({ $ref: "definitions/parent.yaml" });
// @ts-expect-error TS(2532): Object is possibly 'undefined'.
expect(schema.definitions.child).to.deep.equal({ $ref: 'definitions/child.yaml'});
expect(schema.definitions.child).to.deep.equal({ $ref: "definitions/child.yaml" });
});

it('should throw an error if "options.dereference.circular" is false', async () => {
Expand Down
17 changes: 17 additions & 0 deletions test/specs/defs/defs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, it } from "vitest";
import { expect } from "vitest";
import $RefParser from "../../../lib/index.js";
import schemaA from "./schemaA.json";
import schemaB from "./schemaB.json";

describe("Defs", () => {
it("definitions and $defs should both work", async () => {
const parser = new $RefParser();

const resultA = await parser.dereference(schemaA, { mutateInputSchema: false });
await expect(resultA).toMatchFileSnapshot("dereferencedA.json");

const resultB = await parser.dereference(schemaB, { mutateInputSchema: false });
await expect(resultB).toMatchFileSnapshot("dereferencedB.json");
});
});
119 changes: 119 additions & 0 deletions test/specs/defs/dereferencedA.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"SupplierPriceElement": {
"type": "object",
"properties": {
"fee": {
"type": "object",
"properties": {
"modificationFee": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"format": "float"
}
}
}
}
},
"purchaseRate": {
"allOf": [
{
"type": "object",
"properties": {
"amount": {
"type": "number",
"format": "float"
}
}
},
{
"type": "number",
"format": "float"
}
]
}
}
},
"AllFees": {
"type": "object",
"properties": {
"modificationFee": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"format": "float"
}
}
}
}
},
"MonetaryAmount": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"format": "float"
}
}
},
"Amount": {
"type": "number",
"format": "float"
},
"InDetailParent": {
"allOf": [
{
"type": "object",
"properties": {
"amount": {
"type": "number",
"format": "float"
}
}
},
{
"type": "number",
"format": "float"
}
]
}
},
"type": "object",
"properties": {
"fee": {
"type": "object",
"properties": {
"modificationFee": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"format": "float"
}
}
}
}
},
"purchaseRate": {
"allOf": [
{
"type": "object",
"properties": {
"amount": {
"type": "number",
"format": "float"
}
}
},
{
"type": "number",
"format": "float"
}
]
}
}
}
Loading