Add Upstash APL (#78)

This commit is contained in:
Krzysztof Wolski 2022-10-12 15:13:57 +02:00 committed by GitHub
parent be79af2d63
commit bec148e696
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 322 additions and 2 deletions

View file

@ -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 }],

View file

@ -3,4 +3,5 @@ saleor/api.tsx
pnpm-lock.yaml
graphql.schema.json
lib/$path.ts
dist
dist
coverage

View file

@ -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`.

View file

@ -1,3 +1,4 @@
export * from "./apl";
export * from "./file-apl";
export * from "./upstash-apl";
export * from "./vercel-apl";

137
src/APL/upstash-apl.test.ts Normal file
View file

@ -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.'
);
});
});
});
});

162
src/APL/upstash-apl.ts Normal file
View file

@ -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<AplReadyResult> {
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<AplConfiguredResult> {
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"
),
};
}
}