diff --git a/README.md b/README.md index 82ccfcf..529d985 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,48 @@ Takes a $ref string and a root object, and returns the referenced value. -Works in browser & in node (file system refs ignored in browser) +Works in browser & in node (file system refs ignored in browser). + +Easily add support for your own protocols. ## Getting Started `npm install @json-schema-tools/reference-resolver` ```typescript -const referenceResolver = require("@json-schema-tools/reference-resolver").default; +import refRes from "@json-schema-tools/reference-resolver"; +refRes.resolve("#/properties/foo", { properties: { foo: true } }); // returns true +refRes.resolve("https://foo.com/"); // returns what ever json foo.com returns +refRef.resolve("../my-object.json"); // you get teh idea +``` -referenceResolver("#/properties/foo", { properties: { foo: 123 } }); // returns '123' -referenceResolver("https://foo.com/", {}); // returns what ever json foo.com returns -referenceResolver("../my-object.json", {}); // you get teh idea +## Adding custom protocol handlers +```typescript +import referenceResolver from "@json-schema-tools/reference-resolver"; +import JSONSchema from "@json-schema-tools/meta-schema"; + +referenceResolver.protocolHandlerMap.ipfs = (uri) => { + const pretendItsFetchedFromIpfs = { + title: "foo", + type: "string", + } as JSONSchema; + return Promise.resolve(fetchedFromIpfs); +}; + +referenceResolver.protocolHandlerMap["customprotocol"] = (uri) => { + return Promise.resolve({ + type: "string", + title: uri.replace("customprotocol://", ""), + }); +}; + +referenceResolver.resolve("ipfs://80088008800880088008"); +referenceResolver.resolve("customprotocol://foobar"); ``` + ### Contributing How to contribute, build and release are outlined in [CONTRIBUTING.md](CONTRIBUTING.md), [BUILDING.md](BUILDING.md) and [RELEASING.md](RELEASING.md) respectively. Commits in this repository follow the [CONVENTIONAL_COMMITS.md](CONVENTIONAL_COMMITS.md) specification. diff --git a/package-lock.json b/package-lock.json index 86bd299..23852b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -866,6 +866,12 @@ "resolved": "https://registry.npmjs.org/@json-schema-spec/json-pointer/-/json-pointer-0.1.2.tgz", "integrity": "sha512-BYY7IavBjwsWWSmVcMz2A9mKiDD9RvacnsItgmy1xV8cmgbtxFfKmKMtkVpD7pYtkx4mIW4800yZBXueVFIWPw==" }, + "@json-schema-tools/meta-schema": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/@json-schema-tools/meta-schema/-/meta-schema-1.5.10.tgz", + "integrity": "sha512-zg04/H1ADj9fnkEzc5uNa6om00dOR8dUNil3YSuLh46wfTQva3a9JOcqKudAf7NECKldlYI94bSiF++NgLSbBw==", + "dev": true + }, "@types/babel__core": { "version": "7.1.10", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.10.tgz", diff --git a/package.json b/package.json index 7aaaa37..fa5971b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ ], "devDependencies": { "@types/isomorphic-fetch": "0.0.35", + "@json-schema-tools/meta-schema": "^1.5.10", "@types/jest": "^26.0.14", "@types/node": "^14.11.10", "jest": "^24.9.0", diff --git a/src/default-protocol-handler-map.ts b/src/default-protocol-handler-map.ts new file mode 100644 index 0000000..50072b6 --- /dev/null +++ b/src/default-protocol-handler-map.ts @@ -0,0 +1,24 @@ +import { ProtocolHandlerMap } from "./reference-resolver"; +import { InvalidRemoteURLError, NonJsonRefError } from "./errors"; +import { JSONSchema } from "@json-schema-tools/meta-schema"; +import fetch from "isomorphic-fetch"; + +const fetchHandler = async (uri: string): Promise => { + let schemaReq; + try { + schemaReq = await fetch(uri); + } catch (e) { + throw new InvalidRemoteURLError(uri); + } + + try { + return await schemaReq.json() as JSONSchema; + } catch (e) { + throw new NonJsonRefError({ $ref: uri }, e.message); + } +}; + +export default { + "https": fetchHandler, + "http": fetchHandler, +} as ProtocolHandlerMap; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..ec644d2 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,89 @@ +/** + * Error thrown when the fetched reference is not properly formatted JSON or is encoded + * incorrectly + * + * @example + * ```typescript + * + * import Dereferencer, { NonJsonRefError } from "@json-schema-tools/dereferencer"; + * const dereffer = new Dereferencer({}); + * try { await dereffer.resolve(); } + * catch(e) { + * if (e instanceof NonJsonRefError) { ... } + * } + * ``` + * + */ +export class NonJsonRefError implements Error { + public message: string; + public name: string; + + constructor(obj: any, nonJson: string) { + this.name = "NonJsonRefError"; + this.message = [ + "NonJsonRefError", + `The resolved value at the reference: ${obj.$ref} was not JSON.parse 'able`, + `The non-json content in question: ${nonJson}`, + ].join("\n"); + } +} + + +export class NotResolvableError implements Error { + public message: string; + public name: string; + + constructor(ref: string) { + this.name = "NotResolvableError"; + this.message = [ + "NotResolvableError", + `Could not resolve the reference: ${ref}`, + `No protocol handler was found, and it was not found to be an internal reference`, + ].join("\n"); + } +} + +/** + * Error thrown when given an invalid file system path as a reference. + * + */ +export class InvalidRemoteURLError implements Error { + public message: string; + public name: string; + + constructor(ref: string) { + this.name = "InvalidRemoteURLError"; + this.message = [ + "InvalidRemoteURLError", + `The url was not resolvable: ${ref}`, + ].join("\n"); + } +} + +/** + * Error thrown when given an invalid file system path as a reference. + * + * @example + * ```typescript + * + * import Dereferencer, { InvalidFileSystemPathError } from "@json-schema-tools/dereferencer"; + * const dereffer = new Dereferencer({}); + * try { await dereffer.resolve(); } + * catch(e) { + * if (e instanceof InvalidFileSystemPathError) { ... } + * } + * ``` + * + */ +export class InvalidFileSystemPathError implements Error { + public name: string; + public message: string; + + constructor(ref: string) { + this.name = "InvalidFileSystemPathError"; + this.message = [ + "InvalidFileSystemPathError", + `The path was not resolvable: ${ref}`, + ].join("\n"); + } +} diff --git a/src/index-web.ts b/src/index-web.ts index 140009d..ef1e274 100644 --- a/src/index-web.ts +++ b/src/index-web.ts @@ -1,8 +1,10 @@ -import buildReferenceResolver from "./reference-resolver"; import fetch from "isomorphic-fetch"; +import defaultProtocolHandlerMap from "./default-protocol-handler-map"; +import ReferenceResolver, { ProtocolHandlerMap } from "./reference-resolver"; -export default buildReferenceResolver(fetch, { - access: (a: any, b: any, cb: (e: Error) => any) => cb(new Error("cant resolve file refs in a browser... yet")), - readFile: (a: any, b: any, cb: () => any) => { return cb(); }, - constants: { F_OK: 0, R_OK: 0 } //tslint:disable-line -}); +const nodeProtocolHandlerMap: ProtocolHandlerMap = { + ...defaultProtocolHandlerMap, + "file": async () => undefined +}; + +export default new ReferenceResolver(nodeProtocolHandlerMap); diff --git a/src/index.test.ts b/src/index.test.ts index fac48ce..275f3f7 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,30 +1,31 @@ +import { JSONSchema, JSONSchemaObject } from "@json-schema-tools/meta-schema"; +import { InvalidFileSystemPathError, InvalidRemoteURLError, NonJsonRefError } from "./errors"; import referenceResolver from "./index"; -import { NonJsonRefError, InvalidJsonPointerRefError, InvalidFileSystemPathError, InvalidRemoteURLError } from "./reference-resolver"; +import { InvalidJsonPointerRefError } from "./resolve-pointer"; describe("referenceResolver", () => { it("simple", async () => { - const resolvedRef = await referenceResolver("#/properties/foo", { properties: { foo: "boo" } }); + const resolvedRef = await referenceResolver.resolve("#/properties/foo", { properties: { foo: "boo" } }); expect(resolvedRef).toBe("boo"); }); it("file", async () => { - const resolvedRef = await referenceResolver("./src/test-obj.json", {}); + const resolvedRef = await referenceResolver.resolve("./src/test-obj.json"); expect(resolvedRef).toEqual({ type: "string" }); }); it("https uri", async () => { - const resolvedRef = await referenceResolver( - "https://raw.githubusercontent.com/json-schema-tools/meta-schema/master/src/schema.json", - {}, - ); + const uri = "https://meta.json-schema.tools"; + const resolvedRef = await referenceResolver.resolve(uri) as JSONSchemaObject; + expect(resolvedRef.title).toBe("JSONSchema"); }); it("errors on non-json", async () => { expect.assertions(1); try { - await referenceResolver("./src/test-non-json.json", {}); + await referenceResolver.resolve("./src/test-non-json.json"); } catch (e) { expect(e).toBeInstanceOf(NonJsonRefError); } @@ -33,7 +34,7 @@ describe("referenceResolver", () => { it("errors on bad json pointer ref", async () => { expect.assertions(1); try { - await referenceResolver("#/nope", { foo: { bar: true } }); + await referenceResolver.resolve("#/nope", { foo: { bar: true } }); } catch (e) { expect(e).toBeInstanceOf(InvalidJsonPointerRefError); } @@ -42,7 +43,7 @@ describe("referenceResolver", () => { it("errors if file cant be found", async () => { expect.assertions(1); try { - await referenceResolver("../not-real-file", {}); + await referenceResolver.resolve("../not-real-file"); } catch (e) { expect(e).toBeInstanceOf(InvalidFileSystemPathError); } @@ -51,7 +52,7 @@ describe("referenceResolver", () => { it("files are not relative to the src folder", async () => { expect.assertions(1); try { - await referenceResolver("test-schema-1.json", {}); + await referenceResolver.resolve("test-schema-1.json"); } catch (e) { expect(e).toBeInstanceOf(InvalidFileSystemPathError); } @@ -59,20 +60,21 @@ describe("referenceResolver", () => { it("files are relative to the folder the script is run from (in this case, project root)", async () => { expect.assertions(1); - const reffed = await referenceResolver("src/test-schema-1.json", {}); + const reffed = await referenceResolver.resolve("src/test-schema-1.json"); expect(reffed).toBeDefined(); }); it("works with nested folders when using relative file path & no prefixing", async () => { expect.assertions(1); - const resolved = await referenceResolver("nestedtest/test-schema-1.json", {}); + const resolved = await referenceResolver + .resolve("nestedtest/test-schema-1.json") as JSONSchemaObject; expect(resolved.$ref).toBe("./src/test-schema.json"); }); it("errors on urls that arent real", async () => { expect.assertions(1); try { - await referenceResolver("https://not.real.at.all", {}); + await referenceResolver.resolve("https://not.real.at.all"); } catch (e) { expect(e).toBeInstanceOf(InvalidRemoteURLError); } @@ -81,9 +83,9 @@ describe("referenceResolver", () => { it("errors on urls that dont return json", async () => { expect.assertions(1); try { - await referenceResolver("https://open-rpc.org/", {}); + await referenceResolver.resolve("https://open-rpc.org/"); } catch (e) { - expect(e).toBeInstanceOf(InvalidRemoteURLError); + expect(e).toBeInstanceOf(NonJsonRefError); } }); }); @@ -92,13 +94,14 @@ describe("referenceResolver", () => { describe("refs with hash fragment / internal reference component", () => { describe("files", () => { it("works in simple case", async () => { - expect(await referenceResolver("./src/test-obj.json#/type", {})).toBe("string"); + expect(await referenceResolver.resolve("./src/test-obj.json#/type")) + .toBe("string"); }); it("errors when the json pointer is invalid", async () => { expect.assertions(1); try { - await referenceResolver("./src/test-obj.json#balony", {}); + await referenceResolver.resolve("./src/test-obj.json#balony"); } catch (e) { expect(e).toBeInstanceOf(InvalidJsonPointerRefError); } @@ -107,16 +110,16 @@ describe("refs with hash fragment / internal reference component", () => { describe("urls", () => { it("works with forward slashes surrounding the hash", async () => { - expect(await referenceResolver("https://meta.open-rpc.org/#/type", {})).toBe("object"); + expect(await referenceResolver.resolve("https://meta.open-rpc.org/#/type")).toBe("object"); }); it("works without slash infront of hash, but with one after", async () => { - expect(await referenceResolver("https://meta.open-rpc.org#/type", {})).toBe("object"); + expect(await referenceResolver.resolve("https://meta.open-rpc.org#/type")).toBe("object"); }); it("errors when the json pointer is invalid", async () => { expect.assertions(1); try { - await referenceResolver("https://meta.open-rpc.org/#type", {}); + await referenceResolver.resolve("https://meta.open-rpc.org/#type"); } catch (e) { expect(e).toBeInstanceOf(InvalidJsonPointerRefError); } @@ -125,10 +128,23 @@ describe("refs with hash fragment / internal reference component", () => { it("errors when you have 2 hash fragments in 1 ref", async () => { expect.assertions(1); try { - await referenceResolver("https://meta.open-rpc.org/#properties/#openrpc", {}); + await referenceResolver.resolve("https://meta.open-rpc.org/#properties/#openrpc", {}); } catch (e) { expect(e).toBeInstanceOf(InvalidJsonPointerRefError); } }); }); }); + + +describe("adding custom protocol handlers", () => { + it("has a way to add ipfs", () => { + referenceResolver.protocolHandlerMap.ipfs = () => { + // pretend like we are doing ipfs things here + const fetchedFromIpfs = { title: "foo", type: "string" } as JSONSchema; + return Promise.resolve(fetchedFromIpfs); + }; + + referenceResolver.resolve("ipfs://80088008800880088008") + }); +}); diff --git a/src/index.ts b/src/index.ts index 109d95d..da50a75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,30 @@ -import buildReferenceResolver from "./reference-resolver"; -import fetch from "isomorphic-fetch"; +import ReferenceResolver, { ProtocolHandlerMap } from "./reference-resolver"; import * as fs from "fs"; +import { JSONSchema } from "@json-schema-tools/meta-schema"; +import { InvalidRemoteURLError, NonJsonRefError } from "./errors"; +import defaultProtocolHandlerMap from "./default-protocol-handler-map"; -export default buildReferenceResolver(fetch, fs); +const fileExistsAndReadable = (f: string): Promise => { + return new Promise((resolve) => { + return fs.access(f, fs.constants.F_OK | fs.constants.R_OK, (e: any) => { //tslint:disable-line + if (e) { return resolve(false); } + return resolve(true); + }); + }); +}; + +const readFile = (f: string): Promise => { + return new Promise((resolve) => fs.readFile(f, "utf8", (err: any, data: any) => resolve(data))); +}; + +const nodeProtocolHandlerMap: ProtocolHandlerMap = { + ...defaultProtocolHandlerMap, + "file": async (uri) => { + if (await fileExistsAndReadable(uri) === true) { + const fileContents = await readFile(uri); + return JSON.parse(fileContents) as JSONSchema; + } + } +} + +export default new ReferenceResolver(nodeProtocolHandlerMap); diff --git a/src/reference-resolver.ts b/src/reference-resolver.ts index 7107f6a..82932ea 100644 --- a/src/reference-resolver.ts +++ b/src/reference-resolver.ts @@ -1,184 +1,75 @@ -import Ptr from "@json-schema-spec/json-pointer"; - -/** - * Error thrown when the fetched reference is not properly formatted JSON or is encoded - * incorrectly - * - * @example - * ```typescript - * - * import Dereferencer, { NonJsonRefError } from "@json-schema-tools/dereferencer"; - * const dereffer = new Dereferencer({}); - * try { await dereffer.resolve(); } - * catch(e) { - * if (e instanceof NonJsonRefError) { ... } - * } - * ``` - * - */ -export class NonJsonRefError implements Error { - public message: string; - public name: string; - - constructor(obj: any, nonJson: string) { - this.name = "NonJsonRefError"; - this.message = [ - "NonJsonRefError", - `The resolved value at the reference: ${obj.$ref} was not JSON.parse 'able`, - `The non-json content in question: ${nonJson}`, - ].join("\n"); - } -} - -/** - * Error thrown when a JSON pointer is provided but is not parseable as per the RFC6901 - * - * @example - * ```typescript - * - * import Dereferencer, { InvalidJsonPointerRefError } from "@json-schema-tools/dereferencer"; - * const dereffer = new Dereferencer({}); - * try { await dereffer.resolve(); } - * catch(e) { - * if (e instanceof InvalidJsonPointerRefError) { ... } - * } - * ``` - * - */ -export class InvalidJsonPointerRefError implements Error { - public name: string; - public message: string; - - constructor(obj: any) { - this.name = "InvalidJsonPointerRefError"; - this.message = [ - "InvalidJsonPointerRefError", - `The provided RFC6901 JSON Pointer is invalid: ${obj.$ref}`, - ].join("\n"); - } -} - -/** - * Error thrown when given an invalid file system path as a reference. - * - * @example - * ```typescript - * - * import Dereferencer, { InvalidFileSystemPathError } from "@json-schema-tools/dereferencer"; - * const dereffer = new Dereferencer({}); - * try { await dereffer.resolve(); } - * catch(e) { - * if (e instanceof InvalidFileSystemPathError) { ... } - * } - * ``` - * - */ -export class InvalidFileSystemPathError implements Error { - public name: string; - public message: string; - - constructor(ref: string) { - this.name = "InvalidFileSystemPathError"; - this.message = [ - "InvalidFileSystemPathError", - `The path was not resolvable: ${ref}`, - ].join("\n"); - } -} +import { JSONSchema } from "@json-schema-tools/meta-schema"; +import { + InvalidFileSystemPathError, + NonJsonRefError, + NotResolvableError +} from "./errors"; +import resolvePointer from "./resolve-pointer"; const isUrlLike = (s: string) => { - return s.includes("://") || s.includes("localhost:"); + return s.includes("://"); } -/** - * Error thrown when given an invalid file system path as a reference. - * - */ -export class InvalidRemoteURLError implements Error { - public message: string; - public name: string; - - constructor(ref: string) { - this.name = "InvalidRemoteURLError"; - this.message = [ - "InvalidRemoteURLError", - `The url was not resolvable: ${ref}`, - ].join("\n"); - } -} - -export default (fetch: any, fs: any) => { - const fileExistsAndReadable = (f: string): Promise => { - return new Promise((resolve) => { - return fs.access(f, fs.constants.F_OK | fs.constants.R_OK, (e: any) => { //tslint:disable-line - if (e) { return resolve(false); } - return resolve(true); - }); - }); - }; +export interface ProtocolHandlerMap { + [protocol: string]: (uri: string) => Promise; +}; - const readFile = (f: string): Promise => { - return new Promise((resolve) => fs.readFile(f, "utf8", (err: any, data: any) => resolve(data))); - }; - - const resolvePointer = (ref: string, root: any): any => { - try { - const withoutHash = ref.replace("#", ""); - const pointer = Ptr.parse(withoutHash); - return pointer.eval(root); - } catch (e) { - throw new InvalidJsonPointerRefError({ $ref: ref }); - } - }; +export default class ReferenceResolver { + constructor(public protocolHandlerMap: ProtocolHandlerMap) { } /** * Given a $ref string, it will return the underlying pointed-to value. * For remote references, the root object is not used. */ - const resolveReference = async (ref: string, root: any): Promise => { + public async resolve(ref: string, root: JSONSchema = {}): Promise { + // Check if its an internal reference that starts from the root + // Internal references. if (ref[0] === "#") { return Promise.resolve(resolvePointer(ref, root)); } + // Before we check anything else, remove everything after the hash. + // The hash fragment, if anything, is later applied as an internal reference. const hashFragmentSplit = ref.split("#"); let hashFragment; if (hashFragmentSplit.length > 1) { hashFragment = hashFragmentSplit[hashFragmentSplit.length - 1]; } - const hashlessRef = hashFragmentSplit[0]; - if (await fileExistsAndReadable(hashlessRef) === true) { - const fileContents = await readFile(hashlessRef); - let reffedSchema; - try { - reffedSchema = JSON.parse(fileContents); - } catch (e) { - throw new NonJsonRefError({ $ref: ref }, fileContents); - } - + // check if its a runtime-relative filepath before anything (use the 'file') + // protocol handler + let relativePathSchema; + try { + relativePathSchema = await this.protocolHandlerMap.file(hashlessRef); + } catch (e) { + throw new NonJsonRefError({ $ref: ref }, e.message); + } + if (relativePathSchema !== undefined) { + let schema: JSONSchema = relativePathSchema; if (hashFragment) { - reffedSchema = resolvePointer(hashFragment, reffedSchema); + schema = resolvePointer(hashFragment, schema); } - - return reffedSchema; + return schema; } else if (isUrlLike(ref) === false) { throw new InvalidFileSystemPathError(ref); } - let result; - try { - result = await fetch(hashlessRef).then((r: any) => r.json()); - } catch (e) { - throw new InvalidRemoteURLError(ref); - } - - if (hashFragment) { - result = resolvePointer(hashFragment, result); + for (const protocol of Object.keys(this.protocolHandlerMap)) { + if (hashlessRef.startsWith(protocol)) { + const maybeSchema = await this.protocolHandlerMap[protocol](hashlessRef); + + if (maybeSchema !== undefined) { + let schema: JSONSchema = maybeSchema; + if (hashFragment) { + schema = resolvePointer(hashFragment, schema); + } + return schema; + } + } } - return result; - }; - - return resolveReference; -}; + // if we get to the end and nothing has handled it yet, then we are hooped. + throw new NotResolvableError(ref); + } +} diff --git a/src/resolve-pointer.ts b/src/resolve-pointer.ts new file mode 100644 index 0000000..1575377 --- /dev/null +++ b/src/resolve-pointer.ts @@ -0,0 +1,31 @@ +import Ptr from "@json-schema-spec/json-pointer"; + +/** + * Error thrown when a JSON pointer is provided but is not parseable as per the RFC6901 + * + */ +export class InvalidJsonPointerRefError implements Error { + public name: string; + public message: string; + + constructor(ref: string, additionalMsg: string) { + this.name = "InvalidJsonPointerRefError"; + this.message = [ + "InvalidJsonPointerRefError", + `The provided RFC6901 JSON Pointer is invalid: ${ref}`, + "", + "addition info: ", + additionalMsg, + ].join("\n"); + } +} + +export default (ref: string, root: any): any => { + try { + const withoutHash = ref.replace("#", ""); + const pointer = Ptr.parse(withoutHash); + return pointer.eval(root); + } catch (e) { + throw new InvalidJsonPointerRefError(ref, e.message); + } +};