Skip to content

Commit

Permalink
feat: add reference resolution option to allow root level dereferenci…
Browse files Browse the repository at this point in the history
…ng (#305)

* feat: add reference resolution option to allow root level dereferencing

* feat: add reference resolution option to allow root level dereferencing

* skip if browser

* convert path to posix for url first
  • Loading branch information
jonluca committed Sep 20, 2023
1 parent b9f91b2 commit 4d328fa
Show file tree
Hide file tree
Showing 15 changed files with 274 additions and 13 deletions.
4 changes: 3 additions & 1 deletion lib/dereference.ts
Expand Up @@ -169,7 +169,9 @@ function dereference$Ref(
) {
// console.log('Dereferencing $ref pointer "%s" at %s', $ref.$ref, path);

const $refPath = url.resolve(path, $ref.$ref);
const isExternalRef = $Ref.isExternal$Ref($ref);
const shouldResolveOnCwd = isExternalRef && options?.dereference.externalReferenceResolution === "root";
const $refPath = url.resolve(shouldResolveOnCwd ? url.cwd() : path, $ref.$ref);

const cache = dereferencedCache.get($refPath);
if (cache) {
Expand Down
7 changes: 7 additions & 0 deletions lib/index.ts
Expand Up @@ -100,6 +100,13 @@ export class $RefParser {
if (url.isFileSystemPath(args.path)) {
args.path = url.fromFileSystemPath(args.path);
pathType = "file";
} else if (!args.path && args.schema && args.schema.$id) {
// when schema id has defined an URL should use that hostname to request the references,
// instead of using the current page URL
const params = url.parse(args.schema.$id);
const port = params.protocol === "https:" ? 443 : 80;

args.path = `${params.protocol}//${params.hostname}:${port}`;
}

// Resolve the absolute path of the schema
Expand Down
10 changes: 9 additions & 1 deletion lib/options.ts
Expand Up @@ -83,6 +83,13 @@ interface $RefParserOptions {
* @argument {JSONSchemaObject} object The JSON-Schema that the `$ref` resolved to.
*/
onDereference?(path: string, value: JSONSchemaObject): void;

/**
* Whether a reference should resolve relative to its directory/path, or from the cwd
*
* Default: `relative`
*/
externalReferenceResolution?: "relative" | "root";
};
}

Expand Down Expand Up @@ -149,8 +156,9 @@ const getDefaults = () => {
* @type {function}
*/
excludedPathMatcher: () => false,
referenceResolution: "relative",
},
};
} as $RefParserOptions;
return cloneDeep(defaults);
};

Expand Down
6 changes: 3 additions & 3 deletions lib/refs.ts
Expand Up @@ -95,7 +95,7 @@ export default class $Refs {
* @param value The value to assign. Can be anything (object, string, number, etc.)
*/
set(path: any, value: JSONSchema4Type | JSONSchema6Type | JSONSchema7Type) {
const absPath = url.resolve(this._root$Ref.path, path);
const absPath = url.resolve(this._root$Ref.path!, path);
const withoutHash = url.stripHash(absPath);
const $ref = this._$refs[withoutHash];

Expand All @@ -113,7 +113,7 @@ export default class $Refs {
* @protected
*/
_get$Ref(path: any) {
path = url.resolve(this._root$Ref.path, path);
path = url.resolve(this._root$Ref.path!, path);
const withoutHash = url.stripHash(path);
return this._$refs[withoutHash];
}
Expand Down Expand Up @@ -145,7 +145,7 @@ export default class $Refs {
* @protected
*/
_resolve(path: string, pathFromRoot: string, options?: any) {
const absPath = url.resolve(this._root$Ref.path, path);
const absPath = url.resolve(this._root$Ref.path!, path);
const withoutHash = url.stripHash(absPath);
const $ref = this._$refs[withoutHash];

Expand Down
5 changes: 2 additions & 3 deletions lib/resolve-external.ts
Expand Up @@ -92,9 +92,8 @@ function crawl(
* including nested references that are contained in externally-referenced files.
*/
async function resolve$Ref($ref: JSONSchema, path: string, $refs: $Refs, options: Options) {
// console.log('Resolving $ref pointer "%s" at %s', $ref.$ref, path);

const resolvedPath = url.resolve(path, $ref.$ref);
const shouldResolveOnCwd = options.dereference.externalReferenceResolution === "root";
const resolvedPath = url.resolve(shouldResolveOnCwd ? url.cwd() : path, $ref.$ref!);
const withoutHash = url.stripHash(resolvedPath);

// $ref.$ref = url.relative($refs._root$Ref.path, resolvedPath);
Expand Down
9 changes: 5 additions & 4 deletions lib/util/url.ts
Expand Up @@ -16,15 +16,16 @@ const urlEncodePatterns = [/\?/g, "%3F", /#/g, "%23"];
// RegExp patterns to URL-decode special characters for local filesystem paths
const urlDecodePatterns = [/%23/g, "#", /%24/g, "$", /%26/g, "&", /%2C/g, ",", /%40/g, "@"];

export const parse = (u: any) => new URL(u);
export const parse = (u: string | URL) => new URL(u);

/**
* Returns resolved target URL relative to a base URL in a manner similar to that of a Web browser resolving an anchor tag HREF.
*
* @returns
*/
export function resolve(from: any, to: any) {
const resolvedUrl = new URL(to, new URL(from, "resolve://"));
export function resolve(from: string, to: string) {
const fromUrl = new URL(convertPathToPosix(from), "resolve://");
const resolvedUrl = new URL(convertPathToPosix(to), fromUrl);
if (resolvedUrl.protocol === "resolve:") {
// `from` is a relative URL.
const { pathname, search, hash } = resolvedUrl;
Expand Down Expand Up @@ -279,7 +280,7 @@ export function safePointerToPath(pointer: any) {
});
}

export function relative(from: string | undefined, to: string | undefined) {
export function relative(from: string, to: string) {
if (!isFileSystemPath(from) || !isFileSystemPath(to)) {
return resolve(from, to);
}
Expand Down
2 changes: 1 addition & 1 deletion test/specs/callbacks.spec.ts
Expand Up @@ -73,7 +73,7 @@ describe("Callback & Promise syntax", () => {
return async function () {
try {
await $RefParser[method](path.rel("test/specs/invalid/invalid.yaml"));
helper.shouldNotGetCalled;
helper.shouldNotGetCalled();
} catch (err: any) {
expect(err).to.be.an.instanceOf(ParserError);
}
Expand Down
1 change: 1 addition & 0 deletions test/specs/http.spec.ts
@@ -1,3 +1,4 @@
/// <reference lib="dom" />
import { describe, it, beforeEach } from "vitest";
import $RefParser from "../../lib/index.js";

Expand Down
66 changes: 66 additions & 0 deletions test/specs/relative-path/root.spec.ts
@@ -0,0 +1,66 @@
import { afterAll, beforeAll, describe, it } from "vitest";
import $RefParser, { JSONParserError } from "../../../lib/index.js";
import path from "../../utils/path.js";

import { expect, vi } from "vitest";
import helper from "../../utils/helper";

describe.skipIf(process.env.BROWSER)("Schemas with imports in relative and absolute locations work", () => {
describe("Schemas with relative imports that should be resolved from the root", () => {
beforeAll(() => {
vi.spyOn(process, "cwd").mockImplementation(() => {
return __dirname;
});
});
afterAll(() => {
vi.restoreAllMocks();
});
it("should not parse successfully when set to resolve relative (default)", async () => {
const parser = new $RefParser();
try {
await parser.dereference(path.rel("schemas/accountList.json"));
helper.shouldNotGetCalled();
} catch (err) {
expect(err).to.be.an.instanceOf(JSONParserError);
}
});

it("should parse successfully when set to resolve relative (default)", async () => {
const parser = new $RefParser();
const schema = await parser.dereference(path.rel("schemas/accountList.json"), {
dereference: { externalReferenceResolution: "root" },
});
expect(schema).to.eql(parser.schema);
});
});

describe("Schemas with relative imports that should be resolved relatively", () => {
beforeAll(() => {
vi.spyOn(process, "cwd").mockImplementation(() => {
return __dirname;
});
});
afterAll(() => {
vi.restoreAllMocks();
});
it("should parse successfully when set to resolve relative (default)", async () => {
const parser = new $RefParser();
const schema = await parser.dereference(path.rel("schemas-relative/accountList.json"), {
dereference: { externalReferenceResolution: "relative" },
});
expect(schema).to.eql(parser.schema);
});

it("should not parse successfully when set to resolve relative (default)", async () => {
const parser = new $RefParser();
try {
await parser.dereference(path.rel("schemas-relative/accountList.json"), {
dereference: { externalReferenceResolution: "root" },
});
helper.shouldNotGetCalled();
} catch (err) {
expect(err).to.be.an.instanceOf(JSONParserError);
}
});
});
});
26 changes: 26 additions & 0 deletions test/specs/relative-path/schemas-relative/account.json
@@ -0,0 +1,26 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Account",
"$id": "account.json",
"type": "object",
"description": "An account.",
"additionalProperties": false,
"required": [
"accountOwner",
"accountId"
],
"properties": {
"accountOwner": {
"$ref": "user.json"
},
"accountId": {
"$id": "#/properties/accountId",
"type": "string",
"description": "An explanation about the purpose of this instance.",
"default": "",
"examples": [
"186383568343"
]
}
}
}
30 changes: 30 additions & 0 deletions test/specs/relative-path/schemas-relative/accountList.json
@@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "AccountList",
"$id": "accountList.json",
"type": "object",
"description": "An account list result.",
"additionalProperties": false,
"required": [
"data",
"total",
"pages"
],
"properties": {
"data": {
"type": "array",
"default": [],
"items": {
"$ref": "account.json"
}
},
"total": {
"type": "integer",
"description": "The number of total items found."
},
"pages": {
"type": "integer",
"description": "The number of pages found"
}
}
}
34 changes: 34 additions & 0 deletions test/specs/relative-path/schemas-relative/user.json
@@ -0,0 +1,34 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "user.json",
"type": "object",
"title": "User",
"description": "A User",
"default": {},
"additionalProperties": false,
"required": [
"id",
"name",
"email"
],
"properties": {
"id": {
"$id": "#/user/properties/id",
"type": "string",
"description": "The users id.",
"default": ""
},
"name": {
"$id": "#/user/properties/name",
"type": "string",
"description": "The users full name with id.",
"default": ""
},
"email": {
"$id": "#/user/properties/email",
"type": "string",
"description": "The users email address.",
"default": ""
}
}
}
25 changes: 25 additions & 0 deletions test/specs/relative-path/schemas/account.json
@@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Account",
"type": "object",
"description": "An account.",
"additionalProperties": false,
"required": [
"accountOwner",
"accountId"
],
"properties": {
"accountOwner": {
"$ref": "schemas/user.json"
},
"accountId": {
"$id": "#/properties/accountId",
"type": "string",
"description": "An explanation about the purpose of this instance.",
"default": "",
"examples": [
"186383568343"
]
}
}
}
29 changes: 29 additions & 0 deletions test/specs/relative-path/schemas/accountList.json
@@ -0,0 +1,29 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "AccountList",
"type": "object",
"description": "An account list result.",
"additionalProperties": false,
"required": [
"data",
"total",
"pages"
],
"properties": {
"data": {
"type": "array",
"default": [],
"items": {
"$ref": "schemas/account.json"
}
},
"total": {
"type": "integer",
"description": "The number of total items found."
},
"pages": {
"type": "integer",
"description": "The number of pages found"
}
}
}
33 changes: 33 additions & 0 deletions test/specs/relative-path/schemas/user.json
@@ -0,0 +1,33 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"title": "User",
"description": "A User",
"default": {},
"additionalProperties": false,
"required": [
"id",
"name",
"email"
],
"properties": {
"id": {
"$id": "#/user/properties/id",
"type": "string",
"description": "The users id.",
"default": ""
},
"name": {
"$id": "#/user/properties/name",
"type": "string",
"description": "The users full name with id.",
"default": ""
},
"email": {
"$id": "#/user/properties/email",
"type": "string",
"description": "The users email address.",
"default": ""
}
}
}

0 comments on commit 4d328fa

Please sign in to comment.