From e5a5b4c8e34798cc247192ed43dd254dde078912 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Sun, 5 Apr 2020 20:15:02 +0300 Subject: [PATCH 01/16] fix: use origin from base url intead of env global --- src/resolver.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/resolver.ts b/src/resolver.ts index 27e93d9..0112537 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -16,6 +16,10 @@ export class Resolver { protected _base = FOXY_API_URL ) {} + private get _apiUrl() { + return new URL(this._base).origin; + } + private async _cacheIdentifiers(url: string) { const queue: Promise[] = []; @@ -83,14 +87,14 @@ export class Resolver { break; case "https://api.foxycart.com/rels": - result = `${FOXY_API_URL}/rels`; + result = `${this._apiUrl}/rels`; break; case "fx:property_helpers": case "fx:reporting": case "fx:encode": case "fx:token": - result = `${FOXY_API_URL}/${rel.substring(3)}`; + result = `${this._apiUrl}/${rel.substring(3)}`; break; default: @@ -109,19 +113,19 @@ export class Resolver { switch (rel) { case "fx:user": - result = `${FOXY_API_URL}/users/${await throwIfVoid(whenGotUser)}`; + result = `${this._apiUrl}/users/${await throwIfVoid(whenGotUser)}`; break; case "fx:stores": - result = `${FOXY_API_URL}/users/${await throwIfVoid(whenGotUser)}/stores`; + result = `${this._apiUrl}/users/${await throwIfVoid(whenGotUser)}/stores`; break; case "fx:store": - result = `${FOXY_API_URL}/stores/${await throwIfVoid(whenGotStore)}`; + result = `${this._apiUrl}/stores/${await throwIfVoid(whenGotStore)}`; break; case "fx:subscription_settings": - result = `${FOXY_API_URL}/store_subscription_settings/${await throwIfVoid(whenGotStore)}`; + result = `${this._apiUrl}/store_subscription_settings/${await throwIfVoid(whenGotStore)}`; break; case "fx:users": @@ -152,7 +156,7 @@ export class Resolver { case "fx:store_shipping_methods": case "fx:integrations": case "fx:native_integrations": - result = `${FOXY_API_URL}/stores/${await throwIfVoid(whenGotStore)}/${rel.substring(3)}`; + result = `${this._apiUrl}/stores/${await throwIfVoid(whenGotStore)}/${rel.substring(3)}`; break; default: From 7ed53ecf663b042c0082af23ce362b4a584de689 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Sun, 5 Apr 2020 20:17:49 +0300 Subject: [PATCH 02/16] test: add tests for url resolver --- test/resolver.test.ts | 138 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 test/resolver.test.ts diff --git a/test/resolver.test.ts b/test/resolver.test.ts new file mode 100644 index 0000000..6ce4498 --- /dev/null +++ b/test/resolver.test.ts @@ -0,0 +1,138 @@ +import { Auth } from "../src/auth"; +import { Resolver } from "../src/resolver"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import fetch, { RequestInit } from "node-fetch"; +import { FOXY_API_URL } from "../src/env"; +const { Response } = jest.requireActual("node-fetch") as typeof import("node-fetch"); + +jest.mock("node-fetch"); + +describe("Resolver", () => { + const fetchAlias = (fetch as unknown) as jest.MockInstance; + + fetchAlias.mockImplementation(async (url: string, options: RequestInit | undefined) => { + const body = {} as any; + + if (url === "https://api.foxycart.dev/token" && options?.method === "POST") { + body.access_token = "token_mock"; + body.expires_in = 360; + } + + if (url === "https://api.foxycart.dev") { + body._links = { + "fx:store": { href: "https://api.foxycart.dev/stores/123" }, + "fx:user": { href: "https://api.foxycart.dev/users/456" }, + }; + } + + return new Response(JSON.stringify(body), { status: 200 }); + }); + + const auth = new Auth({ + clientId: "0", + clientSecret: "1", + refreshToken: "42", + }); + + it("resolves with the base url when given an empty path", async () => { + const url = "https://api.foxycart.dev"; + expect(await new Resolver(auth, [], url).resolve()).toBe(url); + expect(await new Resolver(auth).resolve()).toBe(FOXY_API_URL); + }); + + it("appends numeric id to the url when provided with path", async () => { + const resolver = new Resolver(auth, [123], "https://api.foxycart.dev"); + expect(await resolver.resolve()).toBe("https://api.foxycart.dev/123"); + }); + + it("skips self rels when provided with path", async () => { + const url = "https://api.foxycart.dev"; + expect(await new Resolver(auth, ["self"], url).resolve()).toBe(url); + }); + + it("adds offset=0 param to url when resolving first rels", async () => { + const resolver = new Resolver(auth, [123, "first"], "https://api.foxycart.dev"); + expect(await resolver.resolve()).toBe("https://api.foxycart.dev/123?offset=0"); + }); + + it("throws an error containing the response text on if fetch fails", async () => { + fetchAlias.mockImplementationOnce(async () => new Response("error", { status: 500 })); + const resolver = new Resolver(auth, ["fx:fakerel"]); + await expect(resolver.resolve()).rejects.toThrow("error"); + }); + + it("skips cache if called with skipCache === true", async () => { + const resolver = new Resolver(auth, ["fx:user"], "https://api.foxycart.dev"); + await resolver.resolve(); + fetchAlias.mockClear(); + + await resolver.resolve(true); + expect(fetchAlias).toHaveBeenCalled(); + }); + + { + const rels = ["fx:store", "fx:user"]; + + for (const rel of rels) { + it(`caches ${rel} once fetched`, async () => { + const resolver = new Resolver(auth, [rel], "https://api.foxycart.dev"); + await resolver.resolve(); + fetchAlias.mockClear(); + + await resolver.resolve(); + expect(fetchAlias).not.toHaveBeenCalled(); + }); + } + } + + { + const map = { + "https://api.foxycart.com/rels": "https://api.foxycart.dev/rels", + "fx:property_helpers": "https://api.foxycart.dev/property_helpers", + "fx:reporting": "https://api.foxycart.dev/reporting", + "fx:encode": "https://api.foxycart.dev/encode", + "fx:token": "https://api.foxycart.dev/token", + "fx:store": "https://api.foxycart.dev/stores/123", + "fx:user": "https://api.foxycart.dev/users/456", + "fx:stores": "https://api.foxycart.dev/users/456/stores", + "fx:subscription_settings": "https://api.foxycart.dev/store_subscription_settings/123", + "fx:users": "https://api.foxycart.dev/stores/123/users", + "fx:attributes": "https://api.foxycart.dev/stores/123/attributes", + "fx:user_accesses": "https://api.foxycart.dev/stores/123/user_accesses", + "fx:customers": "https://api.foxycart.dev/stores/123/customers", + "fx:carts": "https://api.foxycart.dev/stores/123/carts", + "fx:transactions": "https://api.foxycart.dev/stores/123/transactions", + "fx:subscriptions": "https://api.foxycart.dev/stores/123/subscriptions", + "fx:item_categories": "https://api.foxycart.dev/stores/123/item_categories", + "fx:taxes": "https://api.foxycart.dev/stores/123/taxes", + "fx:payment_method_sets": "https://api.foxycart.dev/stores/123/payment_method_sets", + "fx:coupons": "https://api.foxycart.dev/stores/123/coupons", + "fx:template_sets": "https://api.foxycart.dev/stores/123/template_sets", + "fx:template_configs": "https://api.foxycart.dev/stores/123/template_configs", + "fx:cart_templates": "https://api.foxycart.dev/stores/123/cart_templates", + "fx:cart_include_templates": "https://api.foxycart.dev/stores/123/cart_include_templates", + "fx:checkout_templates": "https://api.foxycart.dev/stores/123/checkout_templates", + "fx:receipt_templates": "https://api.foxycart.dev/stores/123/receipt_templates", + "fx:email_templates": "https://api.foxycart.dev/stores/123/email_templates", + "fx:error_entries": "https://api.foxycart.dev/stores/123/error_entries", + "fx:downloadables": "https://api.foxycart.dev/stores/123/downloadables", + "fx:payment_gateways": "https://api.foxycart.dev/stores/123/payment_gateways", + "fx:hosted_payment_gateways": "https://api.foxycart.dev/stores/123/hosted_payment_gateways", + "fx:fraud_protections": "https://api.foxycart.dev/stores/123/fraud_protections", + "fx:payment_methods_expiring": "https://api.foxycart.dev/stores/123/payment_methods_expiring", + "fx:store_shipping_methods": "https://api.foxycart.dev/stores/123/store_shipping_methods", + "fx:integrations": "https://api.foxycart.dev/stores/123/integrations", + "fx:native_integrations": "https://api.foxycart.dev/stores/123/native_integrations", + "fx:process_subscription_webhook": + "https://api.foxycart.dev/stores/123/process_subscription_webhook", + } as const; + + for (const rel in map) { + it(`resolves ${rel}`, async () => { + const resolver = new Resolver(auth, [rel], "https://api.foxycart.dev"); + expect(await resolver.resolve()).toBe(map[rel]); + }); + } + } +}); From 0ba32728c96d83dbc4e02030dd54b9d0186eb4cc Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 17:17:12 +0300 Subject: [PATCH 03/16] feat: make api endpoint configurable in auth --- src/auth.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/auth.ts b/src/auth.ts index 070af22..777dedd 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -20,6 +20,7 @@ type AuthInit = { cache?: Cache; logLevel?: "error" | "warn" | "info" | "http" | "verbose" | "debug" | "silly"; silent?: boolean; + endpoint?: string; }; type PostInit = { @@ -44,6 +45,7 @@ export class Auth { readonly clientId: string; readonly clientSecret: string; readonly refreshToken: string; + readonly endpoint: string; readonly version: Version; readonly cache: Cache; @@ -62,6 +64,7 @@ export class Auth { this.refreshToken = refreshToken; this.version = config?.version ?? "1"; this.cache = config?.cache ?? new MemoryCache(); + this.endpoint = config?.endpoint ?? FOXY_API_URL; this._logger = winston.createLogger({ level: config?.logLevel, @@ -84,7 +87,7 @@ export class Auth { const token = await this.cache.get("fx_auth_access_token"); if (this._validateToken(token)) return (JSON.parse(token) as StoredToken).value; - const response = await fetch(`${FOXY_API_URL}/token`, { + const response = await fetch(`${this.endpoint}/token`, { method: "POST", headers: { "FOXY-API-VERSION": this.version, From 94dc392e6a554e70ef98e31368e0eb91248b1a8c Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 17:17:49 +0300 Subject: [PATCH 04/16] docs: fix property name --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0eb19fb..916ff8e 100755 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ const foxy = new FoxyApi({ ```ts const foxy = new FoxyApi({ // ... - silence: true, // don't log errors and such to console at all + silent: true, // don't log errors and such to console at all }); ``` From 797eecb460e1f49a71172c3f2ac702585051a085 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 17:18:54 +0300 Subject: [PATCH 05/16] refactor: enable esModuleInterop --- src/sender.ts | 2 +- tsconfig.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sender.ts b/src/sender.ts index 1490336..680b499 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -1,5 +1,5 @@ import fetch from "node-fetch"; -import * as traverse from "traverse"; +import traverse from "traverse"; import { Methods } from "./types/methods"; import { HTTPMethod, HTTPMethodWithBody } from "./types/utils"; import { Resolver } from "./resolver"; diff --git a/tsconfig.json b/tsconfig.json index eaef16e..90fb592 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "experimentalDecorators": true, + "esModuleInterop": true, "strict": true, "module": "CommonJS", "target": "ES2015", From 47b77660f102609019f08bce147641372d2de7ac Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 17:20:42 +0300 Subject: [PATCH 06/16] refactor: inline value check in sanitizers --- src/sender.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/sender.ts b/src/sender.ts index 680b499..f983158 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -53,15 +53,13 @@ export class Sender extends Resolver { if (!response.ok) throw new Error(await response.text()); return traverse(await response.json()).map(function (value: any) { - if (!value) return; - // formats locales as "en-US" as opposed to "en_US" - if (this.key === "locale_code") { + if (value && this.key === "locale_code") { return this.update(value.replace("_", "-")); } // formats timezone offset as "+03:00" as opposed to "+0300" - if (this.key && this.key.split("_").includes("date")) { + if (value && this.key && this.key.split("_").includes("date")) { return this.update(value.replace(/([+-])(\d{2})(\d{2})$/gi, "$1$2:$3")); } }); From 3a3515b8edc6e3badd399bd620dd1f54d1fd49c9 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 17:21:27 +0300 Subject: [PATCH 07/16] fix: ensure no extra params are passed from .fetch() to .fetchRaw() --- src/sender.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/sender.ts b/src/sender.ts index f983158..1f601f0 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -73,8 +73,12 @@ export class Sender extends Resolver { entries.forEach((v) => url.searchParams.append(...v)); } + const rawParams: SendRawInit = traverse(params).map(function () { + if (this.key && ["query", "skipCache"].includes(this.key)) this.remove(); + }); + try { - return await this.fetchRaw({ url, ...params }); + return await this.fetchRaw({ url, ...rawParams }); } catch (e) { if (!params?.skipCache && e.message.includes("No route found")) { this._auth.log({ @@ -83,7 +87,7 @@ export class Sender extends Resolver { }); url = new URL(await this.resolve(true)); - return await this.fetchRaw({ url, ...params }); + return this.fetchRaw({ url, ...rawParams }); } else { this._auth.log({ level: "error", message: e.message }); throw e; From 248f74228a2d65119bbe28f932256eb1a0982243 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 17:21:57 +0300 Subject: [PATCH 08/16] test: add tests for sender --- jest.config.json | 3 +- test/mocks/errors.ts | 30 ++++++ test/sender.test.ts | 223 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 test/mocks/errors.ts create mode 100644 test/sender.test.ts diff --git a/jest.config.json b/jest.config.json index bf652bc..c0c30ae 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,5 +1,6 @@ { "collectCoverage": true, "coverageDirectory": ".coverage", + "coveragePathIgnorePatterns": ["/node_modules/", "/test/mocks/"], "testEnvironment": "node" -} \ No newline at end of file +} diff --git a/test/mocks/errors.ts b/test/mocks/errors.ts new file mode 100644 index 0000000..275d019 --- /dev/null +++ b/test/mocks/errors.ts @@ -0,0 +1,30 @@ +const createError = (message: any) => { + return JSON.stringify({ + total: 1, + _links: { + curies: [ + { + name: "fx", + href: "https://api.foxycart.com/rels/{rel}", + templated: true, + }, + ], + }, + _embedded: { + "fx:errors": [ + { + logref: `id-${Date.now()}`, + message, + }, + ], + }, + }); +}; + +export const createNotFoundError = (method: string, url: URL | string) => { + return createError(`No route found for "${method} ${new URL(url.toString()).pathname}"`); +}; + +export const createForbiddenError = (method: string, url: URL | string) => { + return createError(`You can't perform "${method} ${new URL(url.toString()).pathname}"`); +}; diff --git a/test/sender.test.ts b/test/sender.test.ts new file mode 100644 index 0000000..7ece892 --- /dev/null +++ b/test/sender.test.ts @@ -0,0 +1,223 @@ +import { createNotFoundError, createForbiddenError } from "./mocks/errors"; +import { Resolver } from "../src/resolver"; +import { Sender } from "../src/sender"; +import { Auth } from "../src/auth"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import fetch from "node-fetch"; +const { Response } = jest.requireActual("node-fetch") as typeof import("node-fetch"); + +jest.mock("node-fetch"); + +describe("Sender", () => { + const auth = new Auth({ + clientId: "0", + clientSecret: "1", + refreshToken: "42", + endpoint: "https://api.foxy.local", + silent: true, + }); + + const fetchAlias = (fetch as unknown) as jest.MockInstance; + + fetchAlias.mockImplementation(async (url: URL | string, options: RequestInit | undefined) => { + const body = {} as any; + + if (url.toString() === "https://api.foxy.local/token" && options?.method === "POST") { + body.access_token = "token_mock"; + body.expires_in = 360; + } else { + if ((options?.headers as any).Authorization !== "Bearer token_mock") { + return new Response("Unauthorized", { status: 403 }); + } + + if (url.toString() === "https://api.foxy.local/foo/123") { + return new Response(createNotFoundError("GET", url), { status: 404 }); + } + + if (url.toString() === "https://api.foxy.local/bar") { + return new Response(createForbiddenError("GET", url), { status: 403 }); + } + + if (url.toString() === "https://api.foxy.local/foo") { + body._links = body._links ?? {}; + body._links[123] = {}; + body._links[123].href = "https://api.foxy.local/foo/456"; + body.locale_code = "en_US"; + body.date_created = "2016-02-05T10:25:26-0800"; + } + + if (url.toString() === "https://api.foxy.local") { + body._links = body._links ?? {}; + + body._links["fx:foo"] = {}; + body._links["fx:foo"].href = "https://api.foxy.local/foo"; + + body._links["fx:bar"] = {}; + body._links["fx:bar"].href = "https://api.foxy.local/bar"; + } + } + + return new Response(JSON.stringify(body), { status: 200 }); + }); + + it("extends Resolver", () => { + const sender = new Sender(auth); + expect(sender).toBeInstanceOf(Resolver); + }); + + describe(".fetchRaw()", () => { + it("sends a request to the specified url", async () => { + const url = "https://api.foxy.local"; + await new Sender(auth, [], "https://api.foxy.local").fetchRaw({ url }); + expect(fetchAlias).toHaveBeenLastCalledWith(url, expect.any(Object)); + }); + + it("sends a GET request by default", async () => { + await new Sender(auth, [], "https://api.foxy.local").fetchRaw({ + url: "https://api.foxy.local", + }); + + expect(fetchAlias).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ method: "GET" }) + ); + }); + + it("sends a request with a specific HTTP method if provided", async () => { + await new Sender(auth, [], "https://api.foxy.local").fetchRaw({ + url: "https://api.foxy.local", + method: "OPTIONS", + }); + + expect(fetchAlias).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ method: "OPTIONS" }) + ); + }); + + it("appends string body to the request as passed", async () => { + const body = "foo"; + + await new Sender(auth, [], "https://api.foxy.local").fetchRaw({ + url: "https://api.foxy.local", + body, + }); + + expect(fetchAlias).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ body }) + ); + }); + + it("appends a serialized body to the request if provided as object", async () => { + const body = { foo: "bar" }; + + await new Sender(auth, [], "https://api.foxy.local").fetchRaw({ + url: "https://api.foxy.local", + body, + }); + + expect(fetchAlias).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ body: JSON.stringify(body) }) + ); + }); + + it("normalizes locale codes in response", async () => { + const response = await new Sender(auth).fetchRaw({ url: "https://api.foxy.local/foo" }); + expect(response).toHaveProperty("locale_code", "en-US"); + }); + + it("normalizes timezone offsets in response", async () => { + const response = await new Sender(auth).fetchRaw({ url: "https://api.foxy.local/foo" }); + expect(response).toHaveProperty("date_created", "2016-02-05T10:25:26-08:00"); + }); + }); + + describe(".fetch()", () => { + it("calls Resolver.resolve() internally to get the target url", async () => { + const sender = new Sender(auth, ["fx:reporting"], "https://api.foxy.local"); + const spy = jest.spyOn(sender, "resolve"); + await sender.fetch().catch(() => void 0); + + expect(spy).toHaveBeenCalled(); + }); + + it("calls .fetchRaw() internally with the resolved url", async () => { + const sender = new Sender(auth, ["fx:reporting"], "https://api.foxy.local"); + const spy = jest.spyOn(sender, "fetchRaw"); + const url = new URL(await sender.resolve()); + await sender.fetch().catch(() => void 0); + + expect(spy).toHaveBeenLastCalledWith({ url }); + }); + + it("adds query params to the url when provided in args as Object", async () => { + const sender = new Sender(auth, ["fx:reporting"], "https://api.foxy.local"); + const spy = jest.spyOn(sender, "fetchRaw"); + const url = new URL(await sender.resolve()); + + url.searchParams.set("foo", "bar"); + await sender.fetch({ query: { foo: "bar" } }).catch(() => void 0); + + expect(spy).toHaveBeenLastCalledWith({ url }); + }); + + it("adds query params to the url when provided in args as URLSearchParams", async () => { + const sender = new Sender(auth, ["fx:reporting"], "https://api.foxy.local"); + const spy = jest.spyOn(sender, "fetchRaw"); + const url = new URL(await sender.resolve()); + + url.searchParams.set("foo", "bar"); + await sender.fetch({ query: url.searchParams }).catch(() => void 0); + + expect(spy).toHaveBeenLastCalledWith({ url }); + }); + + it("passes params.method to .fetchRaw() when provided in args", async () => { + const sender = new Sender(auth, ["fx:reporting"], "https://api.foxy.local"); + const spy = jest.spyOn(sender, "fetchRaw"); + const method = "OPTIONS"; + await sender.fetch({ method }).catch(() => void 0); + + expect(spy).toHaveBeenLastCalledWith(expect.objectContaining({ method })); + }); + + it("passes params.body to .fetchRaw() when provided in args", async () => { + const sender = new Sender(auth, ["fx:reporting"], "https://api.foxy.local"); + const spy = jest.spyOn(sender, "fetchRaw"); + const body = { foo: "bar" }; + await sender.fetch({ body }).catch(() => void 0); + + expect(spy).toHaveBeenLastCalledWith(expect.objectContaining({ body })); + }); + + it("passes params.skipCache to Resolver.resolve() when provided in args", async () => { + const sender = new Sender(auth, ["fx:reporting"], "https://api.foxy.local"); + const spy = jest.spyOn(sender, "resolve"); + const skipCache = true; + await sender.fetch({ skipCache }).catch(() => void 0); + + expect(spy).toHaveBeenCalledWith(skipCache); + }); + + it("disables smart resolution for request when it fails and tries again", async () => { + const sender = new Sender(auth, ["fx:foo", 123], "https://api.foxy.local"); + const spy = jest.spyOn(sender, "resolve"); + await sender.fetch().catch(() => void 0); + + expect(spy).toHaveBeenNthCalledWith(1, undefined); + expect(spy).toHaveBeenNthCalledWith(2, true); + }); + + it("doesn't attempt 2nd traversal on any other server error except 404", async () => { + const sender = new Sender(auth, ["fx:bar"], "https://api.foxy.local"); + const spy = jest.spyOn(sender, "resolve"); + + await expect(sender.fetch()).rejects.toThrowError(); + expect(spy).toHaveBeenNthCalledWith(1, undefined); + expect(spy).not.toHaveBeenNthCalledWith(2, true); + }); + }); +}); From dd755a9e8f93dc7c4d96c4b92e29cbaa32823317 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 17:50:10 +0300 Subject: [PATCH 09/16] test: add tests for follower --- test/follower.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 test/follower.test.ts diff --git a/test/follower.test.ts b/test/follower.test.ts new file mode 100644 index 0000000..f09049e --- /dev/null +++ b/test/follower.test.ts @@ -0,0 +1,26 @@ +import { Resolver } from "../src/resolver"; +import { Follower } from "../src/follower"; +import { Sender } from "../src/sender"; +import { Auth } from "../src/auth"; + +describe("Follower", () => { + const auth = new Auth({ + clientId: "0", + clientSecret: "1", + refreshToken: "42", + endpoint: "https://api.foxy.local", + silent: true, + }); + + it("extends Resolver", () => expect(new Follower(auth)).toBeInstanceOf(Resolver)); + + it("extends Sender", () => expect(new Follower(auth)).toBeInstanceOf(Sender)); + + it("returns an instance of Follower with the extended path on .follow()", async () => { + const follower = new Follower(auth, [], "https://api.foxy.local"); + const nextFollower = follower.follow("fx:reporting"); + + expect(nextFollower).toBeInstanceOf(Follower); + await expect(nextFollower.resolve()).resolves.toBe("https://api.foxycart.com/reporting"); + }); +}); From 1ef276735c00a336a35ee159fd8a969a839ef32e Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 18:15:30 +0300 Subject: [PATCH 10/16] fix: share auth endpoint with root-level follower and sender --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index cd357e0..7c46d73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,10 +27,10 @@ export class FoxyApi extends Auth { } follow(key: K) { - return new Follower(this, [key]); + return new Follower(this, [key], this.endpoint); } fetchRaw(init: SendRawInit) { - return new Sender(this).fetchRaw(init); + return new Sender(this, [], this.endpoint).fetchRaw(init); } } From a0e29176f39d73246af397e2e7686f12f3a8f899 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 18:15:58 +0300 Subject: [PATCH 11/16] test: add tests for root instance members --- test/index.test.ts | 85 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 16 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index 466f80e..b773b45 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,31 +1,84 @@ import { FoxyApi } from "../src/index"; +import { Follower } from "../src/follower"; +import fetch, { RequestInit } from "node-fetch"; + +const { Response } = jest.requireActual("node-fetch") as typeof import("node-fetch"); +jest.mock("node-fetch"); describe("Entry", () => { it("exports FoxyApi", () => { expect(FoxyApi).not.toBeUndefined(); }); - it("exposes API endpoint as a static property on FoxyApi", () => { - expect(FoxyApi).toHaveProperty("endpoint"); - }); + describe("static members", () => { + it("exposes API endpoint as a static property on FoxyApi", () => { + expect(FoxyApi).toHaveProperty("endpoint"); + }); - it("initializes FoxyApi.endpoint with either FOXY_API_URL or https://api.foxycart.com", () => { - expect(FoxyApi.endpoint).toBe(process.env.FOXY_API_URL ?? "https://api.foxycart.com"); - }); + it("initializes FoxyApi.endpoint with either FOXY_API_URL or https://api.foxycart.com", () => { + expect(FoxyApi.endpoint).toBe(process.env.FOXY_API_URL ?? "https://api.foxycart.com"); + }); - it("exposes sanitize utils as a static property on FoxyApi", () => { - expect(FoxyApi).toHaveProperty("sanitize"); - }); + it("exposes sanitize utils as a static property on FoxyApi", () => { + expect(FoxyApi).toHaveProperty("sanitize"); + }); - it("exposes webhook utils as a static property on FoxyApi", () => { - expect(FoxyApi).toHaveProperty("webhook"); - }); + it("exposes webhook utils as a static property on FoxyApi", () => { + expect(FoxyApi).toHaveProperty("webhook"); + }); + + it("exposes built-in cache providers as a static property on FoxyApi", () => { + expect(FoxyApi).toHaveProperty("cache"); + }); - it("exposes built-in cache providers as a static property on FoxyApi", () => { - expect(FoxyApi).toHaveProperty("cache"); + it("exposes sso utils as a static property on FoxyApi", () => { + expect(FoxyApi).toHaveProperty("sso"); + }); }); - it("exposes sso utils as a static property on FoxyApi", () => { - expect(FoxyApi).toHaveProperty("sso"); + describe("instance members", () => { + const foxy = new FoxyApi({ + clientId: "0", + clientSecret: "1", + refreshToken: "42", + endpoint: "https://api.foxy.local", + }); + + const fetchAlias = (fetch as unknown) as jest.MockInstance; + + fetchAlias.mockImplementation(async (url: string, options: RequestInit | undefined) => { + const body = {} as any; + + if (url === "https://api.foxy.local/token" && options?.method === "POST") { + body.access_token = "token_mock"; + body.expires_in = 360; + } + + if (url === "https://api.foxy.local") { + body._links = { self: { href: "https://api.foxy.local" } }; + } + + return new Response(JSON.stringify(body), { status: 200 }); + }); + + it("makes response objects followable with .from()", async () => { + const example = { _links: { self: { href: "https://api.foxy.local/reporting" } } }; + const follower = foxy.from(example); + + expect(follower).toBeInstanceOf(Follower); + expect(await follower.resolve()).toBe("https://api.foxy.local/reporting"); + }); + + it("follows root rels with .follow()", async () => { + const follower = foxy.follow("fx:reporting"); + + expect(follower).toBeInstanceOf(Follower); + expect(await follower.resolve()).toBe("https://api.foxy.local/reporting"); + }); + + it("allows to .fetchRaw() without having to .follow() first", async () => { + const response = await foxy.fetchRaw({ url: "https://api.foxy.local" }); + expect(response).toHaveProperty(["_links", "self", "href"], "https://api.foxy.local"); + }); }); }); From ef56a17f396c47289ee3d560d7dee1097252bc0a Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 22:46:40 +0300 Subject: [PATCH 12/16] refactor: remove the unnecessary getAccessToken() args Since the Api class extends Auth and getAccessToken is an instance member, there's no point in having an option to pass the constructor parameters for the second time. BREAKING CHANGE: Auth#getAccessToken no longer accepts arguments --- src/auth.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 777dedd..94f2146 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,6 +2,7 @@ import * as winston from "winston"; import * as logform from "logform"; import fetch from "node-fetch"; import { Cache, MemoryCache } from "./utils/cache"; +import { Props } from "./types/props"; import { FOXY_API_CLIENT_ID, @@ -83,7 +84,7 @@ export class Auth { this._logger.log(entry); } - async getAccessToken(init?: PostInit): Promise { + async getAccessToken(): Promise { const token = await this.cache.get("fx_auth_access_token"); if (this._validateToken(token)) return (JSON.parse(token) as StoredToken).value; @@ -95,16 +96,16 @@ export class Auth { }, body: new URLSearchParams({ grant_type: "refresh_token", - refresh_token: init?.refreshToken ?? this.refreshToken, - client_secret: init?.clientSecret ?? this.clientSecret, - client_id: init?.clientId ?? this.clientId, + refresh_token: this.refreshToken, + client_secret: this.clientSecret, + client_id: this.clientId, }), }); const text = await response.text(); if (response.ok) { - const json = JSON.parse(text) as PostResponse; + const json = JSON.parse(text) as Props["fx:token"]; const storedToken: StoredToken = { expiresAt: Date.now() + json.expires_in * 1000, value: json.access_token, From 9cf029b11c0a66f4cfcb135867a1af2e50041df6 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 22:50:37 +0300 Subject: [PATCH 13/16] docs: improve jsdoc coverage and types --- src/auth.ts | 92 ++++++++++++++++++++++++++++++++++++++------ src/follower.ts | 15 +++++++- src/index.ts | 76 +++++++++++++++++++++++++++++++++--- src/resolver.ts | 15 +++++++- src/sender.ts | 71 ++++++++++++++++++++++++++++++---- src/types/props.ts | 18 ++++----- src/types/utils.ts | 10 +++++ src/utils/sso.ts | 58 +++++++++++++++++++++++++++- src/utils/webhook.ts | 28 ++++++++++++++ 9 files changed, 344 insertions(+), 39 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 94f2146..9d20baf 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -14,25 +14,60 @@ import { type Version = "1"; type AuthInit = { + /** + * OAuth2 client ID for your integration. + * If omitted from the config, the value of the `FOXY_API_CLIENT_ID` env var will be used. + * + * @see https://api.foxycart.com/docs/authentication + * @tutorial https://api.foxycart.com/docs/authentication/client_creation + */ clientId?: string; + + /** + * OAuth2 client secret for your integration. + * If omitted from the config, the value of the `FOXY_API_CLIENT_SECRET` env var will be used. + * + * @see https://api.foxycart.com/docs/authentication + * @tutorial https://api.foxycart.com/docs/authentication/client_creation + */ clientSecret?: string; + + /** + * OAuth2 long-term refresh token for your integration. + * If omitted from the config, the value of the `FOXY_API_REFRESH_TOKEN` env var will be used. + * + * @see https://api.foxycart.com/docs/authentication + * @tutorial https://api.foxycart.com/docs/authentication/client_creation + */ refreshToken?: string; + + /** + * API version to use when making requests. + * So far we have just one ("1") and it's used by default. + */ version?: Version; + + /** + * Cache provider to store access token and other temporary values with. + * See the available built-in options under `FoxyApi.cache` or supply your own. + */ cache?: Cache; + + /** + * Determines how verbose our client will be when logging. + * By default, only errors are logged. To log all messages, set this option to `silly`. + */ logLevel?: "error" | "warn" | "info" | "http" | "verbose" | "debug" | "silly"; - silent?: boolean; - endpoint?: string; -}; -type PostInit = { - refreshToken?: string; - clientSecret?: string; - clientId?: string; -}; + /** Pass `true` to completely disable logging (`false` by default). */ + silent?: boolean; -type PostResponse = { - access_token: string; - expires_in: number; + /** + * Allows changing the API endpoint. You'll most likely never need to use this option. + * A value of the `FOXY_API_URL` env var will be used if found. + * Default value is `https://api.foxycart.com`. + */ + endpoint?: string; }; type StoredToken = { @@ -43,11 +78,22 @@ type StoredToken = { export class Auth { private _logger: winston.Logger; + /** OAuth2 client ID for your integration (readonly).*/ readonly clientId: string; + + /** OAuth2 client secret for your integration (readonly). */ readonly clientSecret: string; + + /** OAuth2 refresh token for your integration (readonly). */ readonly refreshToken: string; + + /** API endpoint that requests are made to (readonly). */ readonly endpoint: string; + + /** API version used when making requests (readonly). */ readonly version: Version; + + /** Cache implementation used with this instance (readonly). */ readonly cache: Cache; constructor(config?: AuthInit) { @@ -80,10 +126,34 @@ export class Auth { }); } + /** + * Formats and logs a message if `logLevel` param value allows it. + * + * @example + * + * foxy.log({ + * level: "http", + * message: "Sending a GET request..." + * }); + * + * @param entry the {@link https://www.npmjs.com/package/winston winston} logger options + */ log(entry: winston.LogEntry) { this._logger.log(entry); } + /** + * Fetches and caches the access token for this integration. + * Will return a cached value if there is one and it's still valid, otherwise + * will make an API request and update cache before returning the fresh token. + * + * @example + * + * const token = await foxy.getAccessToken(); + * + * @see https://api.foxycart.com/rels/token + * @tutorial https://api.foxycart.com/docs/authentication + */ async getAccessToken(): Promise { const token = await this.cache.get("fx_auth_access_token"); if (this._validateToken(token)) return (JSON.parse(token) as StoredToken).value; diff --git a/src/follower.ts b/src/follower.ts index 9c3aa9b..cb631eb 100644 --- a/src/follower.ts +++ b/src/follower.ts @@ -1,10 +1,21 @@ import { PathMember, ApiGraph } from "./types/utils"; import { Sender } from "./sender"; +/** + * Part of the API functionality that provides a URL builder + * with IDE autocompletion features powered by TS and JSDoc. + * + * **IMPORTANT:** this class is internal; using it in consumers code is not recommended. + */ export class Follower extends Sender { /** - * Navigate to a nested resource, building a request query. - * Calling this method will not fetch your data immediately. + * Navigates to the nested resource, building a request query. + * Calling this method will not fetch your data immediately. For the list of relations please refer to the + * {@link https://api.foxycart.com/hal-browser/index.html link relationships} page. + * + * @example + * + * const link = foxy.follow("fx:stores").follow(8); * * @param key Nested relation (link) or a numeric id. */ diff --git a/src/index.ts b/src/index.ts index 7c46d73..4f924cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Auth } from "./auth"; import { FOXY_API_URL } from "./env"; import { Follower } from "./follower"; import { Sender, SendRawInit } from "./sender"; +import { ApiGraph, Followable } from "./types/utils"; import { Graph } from "./types/graph"; import { Props } from "./types/props"; import * as cache from "./utils/cache"; @@ -9,28 +10,91 @@ import * as sanitize from "./utils/sanitize"; import * as sso from "./utils/sso"; import * as webhook from "./utils/webhook"; -export class FoxyApi extends Auth { +/** + * Foxy Hypermedia API client for Node. + * To start working with our API, create an instance of this class. + * + * @example + * + * const foxy = new FoxyApi({ + * clientId: "...", // or set process.env.FOXY_API_CLIENT_ID + * clientSecret: "...", // or set process.env.FOXY_API_CLIENT_SECRET + * refreshToken: "..." // or set process.env.FOXY_API_REFRESH_TOKEN + * }); + * + * @see https://api.foxycart.com/docs + * @tutorial https://github.com/foxy/foxy-node-api + */ +class Api extends Auth { + /** The default API endpoint, also a value of `process.env.FOXY_API_URL` if it's set. */ static readonly endpoint = FOXY_API_URL; + + /** A set of useful {@link https://npmjs.com/package/traverse traverse} utils for removing private data from response objects. */ static readonly sanitize = sanitize; + + /** A set of utilities for working with our {@link https://docs.foxycart.com/v/2.0/webhooks webhooks}. */ static readonly webhook = webhook; + + /** A set of basic cache providers to choose from when creating an instance of this class. */ static readonly cache = cache; + + /** A set of utilities for using our {@link https://docs.foxycart.com/v/2.0/sso sso} functionality with your website. */ static readonly sso = sso; /** - * Makes JSON object received with `.fetch()` followable. + * Makes JSON response object followable. + * * @example - * const store = await foxy.follow("fx:store").fetch(); - * const attributes = await foxy.from(store).follow("fx:attributes"); + * + * const store = { _links: { "fx:attributes": { href: "https://api.foxy..." } } }; + * const link = foxy.from(store).follow("fx:attributes"); + * + * // typescript users: specify resource location in the graph for better autocompletion + * const link = foxy.from(...); + * + * @param resource partial response object with the `_links` property containing relations you'd like to follow */ - from(snapshot: any) { - return new Follower(this, [], snapshot._links.self.href); + from(resource: R) { + return new Follower(this, [], resource._links.self.href); } + /** + * Starts building a resource URL from the root level. For the list of relations please refer to the + * {@link https://api.foxycart.com/hal-browser/index.html link relationships} page. + * + * @example + * + * const link = foxy.follow("fx:store").follow("fx:attributes"); + * + * @param key any root relation + */ follow(key: K) { return new Follower(this, [key], this.endpoint); } + /** + * Makes an API request to the specified URL, skipping the path construction + * and resolution. This is what `.fetch()` uses under the hood. Before calling + * this method, consider using a combination of `foxy.from(resource).fetch()` + * or `foxy.follow(...).fetch()` instead. + * + * @example + * + * const response = await foxy.fetchRaw({ + * url: "https://api.foxycart.com/stores", + * method: "POST", + * body: { ... } + * }); + * + * // typescript users: provide relation name to get a better response type + * const response = await foxy.fetchRaw<"fx:stores">(...) + * + * @param init fetch-like request initializer supporting url, method and body params + */ fetchRaw(init: SendRawInit) { return new Sender(this, [], this.endpoint).fetchRaw(init); } } + +export { Api as FoxyApi }; +export { Graph as FoxyApiGraph } from "./types/graph"; diff --git a/src/resolver.ts b/src/resolver.ts index 0112537..e8addfa 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -9,6 +9,12 @@ const throwIfVoid = async (promise: Promise) => { return result; }; +/** + * Part of the API functionality that restores full URLs from + * the ordered relations lists trying to make as few requests as possible. + * + * **IMPORTANT:** this class is internal; using it in consumers code is not recommended. + */ export class Resolver { constructor( protected _auth: Auth, @@ -168,7 +174,14 @@ export class Resolver { } /** - * Resolves the URL based on the given path. + * Restores a full url from the path this resolver has + * been instantiated with making as few requests as possible. + * + * @example + * + * const url = await foxy.follow("fx:store").resolve(); + * + * @param skipCache if true, all optimizations will be disabled and the resolver will perform a full tree traversal */ async resolve(skipCache = false): Promise { let url = this._base; diff --git a/src/sender.ts b/src/sender.ts index 1f601f0..9f0fec8 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -18,20 +18,62 @@ type SendResponse = (Host extends keyof Props ? Props[Host] : any) & { _links: any; }; -export interface SendInit> { - skipCache?: boolean; - method?: Method; - query?: URLSearchParams | Record; - body?: SendBody | string; -} - export interface SendRawInit> { + /** + * The absolute URL (either a `URL` instance or a string) + * to send the request to. Required. + */ url: URL | string; + + /** + * Request payload, either already serialized or in form of a serializable object. + * Not applicable to some request methods (e.g. `GET`). Empty by default. + */ body?: SendBody | string; + + /** + * {@link https://developer.mozilla.org/docs/Web/HTTP/Methods HTTP method} to use in this request. + * Different relations support different sets of methods. If omitted, `GET` will be used by default. + */ method?: Method; } +export type SendInit> = Omit, "url"> & { + /** + * If true, all URL resolution optimizations will be disabled for this requests. + * This option is `false` by default. + */ + skipCache?: boolean; + + /** + * A key-value map containing the query parameters that you'd like to add to the URL when it's resolved. + * You can also use `URLSearchParams` if convenient. Empty set by default. + */ + query?: URLSearchParams | Record; +}; + +/** + * Part of the API functionality that sends the API requests and + * normalizes the responses if necessary. + * + * **IMPORTANT:** this class is internal; using it in consumers code is not recommended. + */ export class Sender extends Resolver { + /** + * Makes an API request to the specified URL, skipping the path construction + * and resolution. This is what `.fetch()` uses under the hood. Before calling + * this method, consider using a combination of `foxy.from(resource).fetch()` + * or `foxy.follow(...).fetch()` instead. + * + * @example + * + * const response = await foxy.follow("fx:store").fetchRaw({ + * url: "https://api.foxycart.com/stores/8", + * method: "POST", + * body: { ... } + * }); + * @param init fetch-like request initializer supporting url, method and body params + */ async fetchRaw(params: SendRawInit): Promise> { const method = params.method ?? "GET"; @@ -65,6 +107,21 @@ export class Sender extends Resolver { }); } + /** + * Resolves the resource URL and makes an API request + * according to the given configuration. A GET request + * without query parameters will be sent by default. Refer to our + * {@link https://api.foxycart.com/docs/cheat-sheet cheatsheet} + * for the list of available query parameters and HTTP methods. + * + * @example + * + * const { store_version } = await foxy.follow("fx:store").fetch({ + * query: { fields: "store_version" } + * }); + * + * @param params API request options such as method, query or body + */ async fetch(params?: SendInit): Promise> { let url = new URL(await this.resolve(params?.skipCache)); diff --git a/src/types/props.ts b/src/types/props.ts index 08ebaaa..95b2481 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -1735,18 +1735,16 @@ export interface Props { }; "fx:token": { - /** The OAuth grant type being requested as used for {@link http://tools.ietf.org/html/rfc6749#page-10 Refresh Tokens} and the {@link https://tools.ietf.org/html/rfc6749#page-24 Authorization Code Grant}. */ - grant_type: string; /** The OAuth refresh token. This token is returned in the response whenever creating a client, user or store or when doing an authorization code grant. */ refresh_token: string; - /** Authorization Code granted via the Authorization Code grant. */ - code: string; - /** The redirect uri defined for this OAuth client. Used when doing Authorization Code grant and it must match what is stored for the OAuth client. */ - redirect_uri: string; - /** The client_id for your FoxyCart client resource. */ - client_id: string; - /** Although the OAuth 2.0 spec supports passing the client secret as a url param, it is much better to use HTTP Basic auth instead. */ - client_secret: string; + /** The OAuth access token. Access tokens expire after 7200 seconds (2 hours). */ + access_token: string; + /** Lifespan of the access token in seconds. */ + expires_in: number; + /** Returned token type, e.g. `bearer`. */ + token_type: string; + /** The scopes assigned to this token. */ + scope: string; }; "fx:property_helpers": { diff --git a/src/types/utils.ts b/src/types/utils.ts index 71d5ad4..3cc0b50 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -12,3 +12,13 @@ export interface ApiGraph { [key: string]: never | T; [key: number]: T; } + +/** + * Any resource received from the API that includes + * a set of links to other resources (relations). + */ +export interface Followable { + _links: { + [key: string]: { href: string }; + }; +} diff --git a/src/utils/sso.ts b/src/utils/sso.ts index bd932cf..749422a 100644 --- a/src/utils/sso.ts +++ b/src/utils/sso.ts @@ -1,20 +1,74 @@ import * as crypto from "crypto"; interface Options { + /** + * Integer, epoch time. The future time that this authentication token will expire. + * If a customer makes a checkout request with an expired authentication token, then FoxyCart + * will redirect them to the endpoint in order to generate a new token. You can make use of the + * timestamp value you received to your endpoint in the GET parameters, and add additional time + * to it for how long you want it to be valid for. For example, adding 3600 to the timestamp will + * extend it by 3600 seconds, or 30 minutes. + * + * @see https://docs.foxycart.com/v/2.0/sso#the_details + */ timestamp?: number; + + /** + * The FoxyCart session ID. This is necessary to prevent issues with users with 3rd party cookies + * disabled and stores that are not using a custom subdomain. + * + * @see https://docs.foxycart.com/v/2.0/sso#the_details + */ session?: string; - customer: string; + + /** + * Integer, the customer ID, as determined and stored when the user is first created or synched using the API. + * If a customer is not authenticated and you would like to allow them through to checkout, + * enter a customer ID of 0 (the number). + * + * @see https://docs.foxycart.com/v/2.0/sso#the_details + */ + customer: number; + + /** + * Store's {@link https://docs.foxycart.com/v/2.0/store_secret secret key}. + * A random 60 character key that helps secure sensitive information provided by some of our functionality. + * + * @see https://docs.foxycart.com/v/2.0/sso#the_details + */ secret: string; + + /** + * The unique FoxyCart subdomain URL for your cart, checkout, and receipt + * that usually looks like this: `https://yourdomain.foxycart.com`. + * + * @see https://docs.foxycart.com/v/2.0/sso#the_details + * @see https://api.foxycart.com/rels/store + */ domain: string; } +/** + * Generates an SSO url for the given configuration. + * + * @example + * + * const url = FoxyApi.sso.createUrl({ + * customer: 123, + * secret: "...", + * domain: "https://yourdomain.foxycart.com" + * }); + * + * @param options sso url configuration + * @tutorial https://docs.foxycart.com/v/2.0/sso#the_details + */ export function createUrl(options: Options) { const timestamp = options.timestamp ?? Date.now(); const decodedToken = `${options.customer}|${timestamp}|${options.secret}`; const encodedToken = crypto.createHash("sha1").update(decodedToken); const url = new URL("/checkout", options.domain); - url.searchParams.append("fc_customer_id", options.customer); + url.searchParams.append("fc_customer_id", options.customer.toString()); url.searchParams.append("fc_auth_token", encodedToken.digest("hex")); url.searchParams.append("timestamp", String(timestamp)); diff --git a/src/utils/webhook.ts b/src/utils/webhook.ts index db486d3..d589553 100755 --- a/src/utils/webhook.ts +++ b/src/utils/webhook.ts @@ -1,11 +1,39 @@ import * as crypto from "crypto"; export interface VerificationParams { + /** + * The `Foxy-Webhook-Signature` header value received with the webhook. + * @see https://wiki.foxycart.com/v/2.0/webhooks + */ signature: string; + + /** + * The serialized (string) request body received with the webhook. + * @see https://wiki.foxycart.com/v/2.0/webhooks + */ payload: string; + + /** + * The encryption key for this particular webhook. + * @see https://wiki.foxycart.com/v/2.0/webhooks + */ key: string; } +/** + * Verifies that the webhook your app has received was indeed sent from our servers. + * + * @example + * + * const isVerified = FoxyApi.webhook.verify({ + * signature: "...", + * payload: "...", + * key: "..." + * }); + * + * @param params info received with the webhook that needs validation + * @tutorial https://wiki.foxycart.com/v/2.0/webhooks#validating_the_payload + */ export function verify(params: VerificationParams): boolean { const computedSignature = crypto .createHmac("sha256", params.key) From a041acd49edf7c18414a3f8a64f37ff5a6488273 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 22:51:59 +0300 Subject: [PATCH 14/16] build: add a test:watch npm script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 5291021..1fe99ff 100755 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ ], "scripts": { "test": "jest --config ./jest.config.json", + "test:watch": "jest --watch --config ./jest.config.json", "lint": "tsc --noEmit && eslint \"{src,test}/**/*.ts\" --quiet --fix", "build": "tsc" }, From 8f3c9e1fcb0df5d3a64ca80de15f1bdb1c2fbde3 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 22:52:19 +0300 Subject: [PATCH 15/16] build: bump the version tag --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1fe99ff..c7ed0c8 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxy.io/node-api", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "description": "FoxyCart hAPI client for Node", "main": "dist/index.js", "types": "dist/index.ts", From 0a52c792c8c649b3c739de2b48ce7d668cfa051a Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Mon, 6 Apr 2020 23:01:14 +0300 Subject: [PATCH 16/16] docs(readme): add local setup info --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 916ff8e..7319f46 100755 --- a/README.md +++ b/README.md @@ -148,3 +148,25 @@ await store.fetch({ ```ts await store.fetch({ method: "DELETE" }); ``` + +## Development + +To get started, clone this repo locally and install the dependencies: + +```bash +git clone https://github.com/foxy/foxy-node-api.git +npm install +``` + +Running tests: + +```bash +npm run test # runs all tests and exits +npm run test:watch # looks for changes and re-runs tests as you code +``` + +Committing changes with [commitizen](https://github.com/commitizen/cz-cli): + +```bash +git commit # precommit hooks will lint the staged files and help you format your message correctly +```