From 4d328fa522be2e8b29d857c66625903e97cd8ac6 Mon Sep 17 00:00:00 2001 From: JonLuca De Caro Date: Tue, 19 Sep 2023 17:27:20 -0700 Subject: [PATCH] feat: add reference resolution option to allow root level dereferencing (#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 --- lib/dereference.ts | 4 +- lib/index.ts | 7 ++ lib/options.ts | 10 ++- lib/refs.ts | 6 +- lib/resolve-external.ts | 5 +- lib/util/url.ts | 9 +-- test/specs/callbacks.spec.ts | 2 +- test/specs/http.spec.ts | 1 + test/specs/relative-path/root.spec.ts | 66 +++++++++++++++++++ .../schemas-relative/account.json | 26 ++++++++ .../schemas-relative/accountList.json | 30 +++++++++ .../relative-path/schemas-relative/user.json | 34 ++++++++++ test/specs/relative-path/schemas/account.json | 25 +++++++ .../relative-path/schemas/accountList.json | 29 ++++++++ test/specs/relative-path/schemas/user.json | 33 ++++++++++ 15 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 test/specs/relative-path/root.spec.ts create mode 100644 test/specs/relative-path/schemas-relative/account.json create mode 100644 test/specs/relative-path/schemas-relative/accountList.json create mode 100644 test/specs/relative-path/schemas-relative/user.json create mode 100644 test/specs/relative-path/schemas/account.json create mode 100644 test/specs/relative-path/schemas/accountList.json create mode 100644 test/specs/relative-path/schemas/user.json diff --git a/lib/dereference.ts b/lib/dereference.ts index f07216ad..b97f12b5 100644 --- a/lib/dereference.ts +++ b/lib/dereference.ts @@ -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) { diff --git a/lib/index.ts b/lib/index.ts index ae770b1d..bc0b0905 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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 diff --git a/lib/options.ts b/lib/options.ts index 62afe6bf..54feb74a 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -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"; }; } @@ -149,8 +156,9 @@ const getDefaults = () => { * @type {function} */ excludedPathMatcher: () => false, + referenceResolution: "relative", }, - }; + } as $RefParserOptions; return cloneDeep(defaults); }; diff --git a/lib/refs.ts b/lib/refs.ts index 3724b667..acc48d6f 100644 --- a/lib/refs.ts +++ b/lib/refs.ts @@ -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]; @@ -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]; } @@ -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]; diff --git a/lib/resolve-external.ts b/lib/resolve-external.ts index 2bca6191..775abe71 100644 --- a/lib/resolve-external.ts +++ b/lib/resolve-external.ts @@ -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); diff --git a/lib/util/url.ts b/lib/util/url.ts index 183aca44..c4df151a 100644 --- a/lib/util/url.ts +++ b/lib/util/url.ts @@ -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; @@ -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); } diff --git a/test/specs/callbacks.spec.ts b/test/specs/callbacks.spec.ts index 571d83d2..d61f491c 100644 --- a/test/specs/callbacks.spec.ts +++ b/test/specs/callbacks.spec.ts @@ -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); } diff --git a/test/specs/http.spec.ts b/test/specs/http.spec.ts index e3d3fcb3..902c345f 100644 --- a/test/specs/http.spec.ts +++ b/test/specs/http.spec.ts @@ -1,3 +1,4 @@ +/// import { describe, it, beforeEach } from "vitest"; import $RefParser from "../../lib/index.js"; diff --git a/test/specs/relative-path/root.spec.ts b/test/specs/relative-path/root.spec.ts new file mode 100644 index 00000000..2088b382 --- /dev/null +++ b/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); + } + }); + }); +}); diff --git a/test/specs/relative-path/schemas-relative/account.json b/test/specs/relative-path/schemas-relative/account.json new file mode 100644 index 00000000..82c95f42 --- /dev/null +++ b/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" + ] + } + } +} diff --git a/test/specs/relative-path/schemas-relative/accountList.json b/test/specs/relative-path/schemas-relative/accountList.json new file mode 100644 index 00000000..927850ef --- /dev/null +++ b/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" + } + } +} diff --git a/test/specs/relative-path/schemas-relative/user.json b/test/specs/relative-path/schemas-relative/user.json new file mode 100644 index 00000000..2f32b425 --- /dev/null +++ b/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": "" + } + } +} diff --git a/test/specs/relative-path/schemas/account.json b/test/specs/relative-path/schemas/account.json new file mode 100644 index 00000000..f0d5e5c1 --- /dev/null +++ b/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" + ] + } + } +} diff --git a/test/specs/relative-path/schemas/accountList.json b/test/specs/relative-path/schemas/accountList.json new file mode 100644 index 00000000..2933ba2e --- /dev/null +++ b/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" + } + } +} diff --git a/test/specs/relative-path/schemas/user.json b/test/specs/relative-path/schemas/user.json new file mode 100644 index 00000000..d59b69f5 --- /dev/null +++ b/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": "" + } + } +}