Add Upstash APL (#78)
This commit is contained in:
parent
be79af2d63
commit
bec148e696
6 changed files with 322 additions and 2 deletions
|
@ -9,7 +9,7 @@
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"prettier" // prettier *has* to be the last one, to avoid conflicting rules
|
"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"],
|
"plugins": ["simple-import-sort", "@typescript-eslint"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-extraneous-dependencies": ["error", { "devDependencies": true }],
|
"import/no-extraneous-dependencies": ["error", { "devDependencies": true }],
|
||||||
|
|
|
@ -4,3 +4,4 @@ pnpm-lock.yaml
|
||||||
graphql.schema.json
|
graphql.schema.json
|
||||||
lib/$path.ts
|
lib/$path.ts
|
||||||
dist
|
dist
|
||||||
|
coverage
|
19
docs/apl.md
19
docs/apl.md
|
@ -137,3 +137,22 @@ sequenceDiagram
|
||||||
SSI->>V: Set auth data as environment variables
|
SSI->>V: Set auth data as environment variables
|
||||||
V->>A: Redeploy the application
|
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`.
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./apl";
|
export * from "./apl";
|
||||||
export * from "./file-apl";
|
export * from "./file-apl";
|
||||||
|
export * from "./upstash-apl";
|
||||||
export * from "./vercel-apl";
|
export * from "./vercel-apl";
|
||||||
|
|
137
src/APL/upstash-apl.test.ts
Normal file
137
src/APL/upstash-apl.test.ts
Normal 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
162
src/APL/upstash-apl.ts
Normal 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"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue