From bec148e69692420b285b44ead76480b8cda1ff1c Mon Sep 17 00:00:00 2001 From: Krzysztof Wolski Date: Wed, 12 Oct 2022 15:13:57 +0200 Subject: [PATCH] Add Upstash APL (#78) --- .eslintrc | 2 +- .prettierignore | 3 +- docs/apl.md | 19 +++++ src/APL/index.ts | 1 + src/APL/upstash-apl.test.ts | 137 ++++++++++++++++++++++++++++++ src/APL/upstash-apl.ts | 162 ++++++++++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 src/APL/upstash-apl.test.ts create mode 100644 src/APL/upstash-apl.ts diff --git a/.eslintrc b/.eslintrc index 20e535a..c54fd77 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,7 +9,7 @@ "plugin:@typescript-eslint/recommended", "prettier" // prettier *has* to be the last one, to avoid conflicting rules ], - "ignorePatterns": ["pnpm-lock.yaml", "dist"], + "ignorePatterns": ["pnpm-lock.yaml", "dist", "coverage"], "plugins": ["simple-import-sort", "@typescript-eslint"], "rules": { "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], diff --git a/.prettierignore b/.prettierignore index c50265d..bdf1ff4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,5 @@ saleor/api.tsx pnpm-lock.yaml graphql.schema.json lib/$path.ts -dist \ No newline at end of file +dist +coverage \ No newline at end of file diff --git a/docs/apl.md b/docs/apl.md index 1f81cc4..8370c62 100644 --- a/docs/apl.md +++ b/docs/apl.md @@ -137,3 +137,22 @@ sequenceDiagram SSI->>V: Set auth data as environment variables V->>A: Redeploy the application ``` + +### UpstashAPL + +[Upstash](https://upstash.com) is a Redis SaaS targeting serverless applications. It's free tier is more than enough to start developing multi-tenant Saleor Apps, and credit card info is not required to create an account. + +APL implementation use REST endpoints to store the data, so no extra dependencies are required. + +To use UpstashAPL, you'll need endpoint URL and password. Both will be available at [dashboard](https://console.upstash.com/) after creating a database. Credentials can be passed to APL during it's creation: + +```ts +import { UpstashAPL } from "@saleor/app-sdk/APL"; + +const apl = new UpstashAPL({ + restToken: "XXXXXXXXXXXXXX", + restUrl: "https://eu2-red-panda-00000.upstash.io", +}); +``` + +Or using environment variables: `UPSTASH_TOKEN`, `UPSTASH_URL`. diff --git a/src/APL/index.ts b/src/APL/index.ts index 0598c52..59100fc 100644 --- a/src/APL/index.ts +++ b/src/APL/index.ts @@ -1,3 +1,4 @@ export * from "./apl"; export * from "./file-apl"; +export * from "./upstash-apl"; export * from "./vercel-apl"; diff --git a/src/APL/upstash-apl.test.ts b/src/APL/upstash-apl.test.ts new file mode 100644 index 0000000..398364d --- /dev/null +++ b/src/APL/upstash-apl.test.ts @@ -0,0 +1,137 @@ +import fetch from "node-fetch"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AuthData } from "./apl"; +import { UpstashAPL, UpstashAPLConfig, UpstashAPLVariables } from "./upstash-apl"; + +vi.mock("node-fetch", () => ({ + default: vi.fn().mockImplementation(() => ""), +})); + +const mockFetch = vi.mocked(fetch); + +const aplConfig: UpstashAPLConfig = { + restToken: "token", + restURL: "http://example.com", +}; + +const stubAuthData: AuthData = { + domain: "example.com", + token: "example-token", +}; + +describe("APL", () => { + const initialEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...initialEnv }; + vi.resetModules(); + }); + + describe("UpstashAPL", () => { + it("Test if constructor use options over environment variables", async () => { + process.env[UpstashAPLVariables.UPSTASH_TOKEN] = "environment"; + process.env[UpstashAPLVariables.UPSTASH_URL] = "environment"; + + const apl = await new UpstashAPL({ restToken: "option", restURL: "option" }); + // eslint-disable-next-line dot-notation + expect(apl["restToken"]).toBe("option"); + // eslint-disable-next-line dot-notation + expect(apl["restURL"]).toBe("option"); + }); + + describe("set", () => { + it("Successful save of the auth data", async () => { + // @ts-ignore Ignore type of mocked response + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ result: "ok" }), + }); + const apl = new UpstashAPL({ + restURL: "https://example.com", + restToken: "token", + }); + await apl.set({ domain: "example.com", token: "token" }); + expect(mockFetch).toBeCalledWith( + "https://example.com", + + { + // eslint-disable-next-line quotes + body: '["SET", "example.com", "token"]', + headers: { + "Content-Type": "application/json", + Authorization: "Bearer token", + }, + method: "POST", + } + ); + }); + + it("Raise error when register service returns non 200 response", async () => { + // @ts-ignore Ignore type of mocked response + mockFetch.mockResolvedValue({ status: 500 }); + + const apl = new UpstashAPL({ + restURL: "https://example.com", + restToken: "token", + }); + await expect(apl.set({ domain: "example.com", token: "token" })).rejects.toThrow( + "Upstash APL responded with the code 500" + ); + }); + }); + + describe("get", () => { + describe("Read existing auth data from env", () => { + it("Read existing auth data", async () => { + // @ts-ignore Ignore type of mocked response + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ + result: stubAuthData.token, + }), + }); + const apl = new UpstashAPL(aplConfig); + + expect(await apl.get(stubAuthData.domain)).toStrictEqual(stubAuthData); + }); + + it("Return undefined when unknown domain requested", async () => { + // @ts-ignore Ignore type of mocked response + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ + result: null, + }), + }); + const apl = new UpstashAPL(aplConfig); + + expect(await apl.get("unknown-domain.example.com")).toBe(undefined); + }); + }); + }); + + describe("getAll", () => { + describe("Check if error is raised", () => { + it("Read existing auth data", async () => { + const apl = new UpstashAPL(aplConfig); + await expect(apl.getAll()).rejects.toThrow("UpstashAPL does not support getAll method"); + }); + }); + }); + + describe("isReady", () => { + it("Returns error with message mentioning missing configuration variables", async () => { + const apl = new UpstashAPL(); + + const result = await apl.isReady(); + expect(result.ready).toBeFalsy(); + // @ts-ignore + expect(result.error.message).toEqual( + // eslint-disable-next-line quotes + 'Configuration values for: "restToken", "restURL" not found or is empty. Pass values to constructor of use env variables.' + ); + }); + }); + }); +}); diff --git a/src/APL/upstash-apl.ts b/src/APL/upstash-apl.ts new file mode 100644 index 0000000..f1044ab --- /dev/null +++ b/src/APL/upstash-apl.ts @@ -0,0 +1,162 @@ +/* eslint-disable class-methods-use-this */ +// eslint-disable-next-line max-classes-per-file +import fetch, { Response } from "node-fetch"; + +import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl"; +import { createAPLDebug } from "./apl-debug"; + +const debug = createAPLDebug("UpstashAPL"); + +type SuccessResponse = { result: string }; +type ErrorResponse = { error: string }; +type UpstashResponse = SuccessResponse | ErrorResponse; + +export const UpstashAPLVariables = { + UPSTASH_TOKEN: "UPSTASH_TOKEN", + UPSTASH_URL: "UPSTASH_URL", +}; + +export class UpstashAplMisconfiguredError extends Error { + constructor(public missingVars: string[]) { + super( + `Configuration values for: ${missingVars + .map((v) => `"${v}"`) + .join(", ")} not found or is empty. Pass values to constructor of use env variables.` + ); + } +} + +export class UpstashAplNotConfiguredError extends Error {} + +export type UpstashAPLConfig = { + restURL: string; + restToken: string; +}; + +/** + * Upstash APL + * + * Use [Upstash](https://upstash.com) which is SaaS Redis provider, to store auth data. + * You can create free developer account and use it to develop multi-tenant apps. + * + * Configuration require 2 elements: URL to the REST service and auth token. Both can be + * found in the Upstash dashboard. You can choose to store them using environment variables + * (`UPSTASH_URL` and `UPSTASH_TOKEN`) or pass directly to APL's constructor. + */ +export class UpstashAPL implements APL { + private restURL?: string; + + private restToken?: string; + + constructor(config?: UpstashAPLConfig) { + const restURL = config?.restURL || process.env[UpstashAPLVariables.UPSTASH_URL]; + const restToken = config?.restToken || process.env[UpstashAPLVariables.UPSTASH_TOKEN]; + + this.restURL = restURL; + this.restToken = restToken; + } + + private async upstashRequest(requestBody: string) { + debug("Sending request to Upstash"); + if (!this.restURL || !this.restToken) { + throw new Error( + "UpstashAPL is not configured. See https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md" + ); + } + let response: Response; + try { + response = await fetch(this.restURL, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.restToken}` }, + body: requestBody, + }); + } catch (error) { + debug("Error during sending the data:", error); + throw new Error(`UpstashAPL was unable to perform a request ${error}`); + } + if (response.status >= 400 || response.status < 200) { + debug("Non 200 response code. Upstash responded with %j", response); + throw new Error(`Upstash APL responded with the code ${response.status}`); + } + const parsedResponse = (await response.json()) as UpstashResponse; + if ("error" in parsedResponse) { + debug("Upstash API responded with error: %s", parsedResponse.error); + throw new Error("Upstash APL was not able to perform operation"); + } + debug("Register service responded successfully"); + return parsedResponse.result; + } + + private async saveDataToUpstash(authData?: AuthData) { + debug("saveDataToUpstash() called with: %j", { + domain: authData?.domain, + token: authData?.token.substring(0, 4), + }); + + await this.upstashRequest(`["SET", "${authData?.domain}", "${authData?.token}"]`); + } + + private async deleteDataFromUpstash(domain: string) { + await this.upstashRequest(`["DEL", "${domain}"]`); + } + + private async fetchDataFromUpstash(domain: string) { + const result = await this.upstashRequest(`["GET", "${domain}"]`); + if (result) { + return { domain, token: result }; + } + return undefined; + } + + async get(domain: string) { + return this.fetchDataFromUpstash(domain); + } + + async set(authData: AuthData) { + await this.saveDataToUpstash(authData); + } + + async delete(domain: string) { + await this.deleteDataFromUpstash(domain); + } + + async getAll() { + throw new Error("UpstashAPL does not support getAll method"); + return []; + } + + // eslint-disable-next-line class-methods-use-this + async isReady(): Promise { + const missingConf: string[] = []; + if (!this.restToken) { + missingConf.push("restToken"); + } + if (!this.restURL) { + missingConf.push("restURL"); + } + + if (missingConf.length > 0) { + return { + ready: false, + error: new UpstashAplMisconfiguredError(missingConf), + }; + } + + return { + ready: true, + }; + } + + async isConfigured(): Promise { + return this.restToken && this.restURL + ? { + configured: true, + } + : { + configured: false, + error: new UpstashAplNotConfiguredError( + "UpstashAPL not configured. Check if REST URL and token provided in constructor or env" + ), + }; + } +}