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",
|
||||
"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 }],
|
||||
|
|
|
@ -3,4 +3,5 @@ saleor/api.tsx
|
|||
pnpm-lock.yaml
|
||||
graphql.schema.json
|
||||
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
|
||||
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 "./file-apl";
|
||||
export * from "./upstash-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