Skip to content

Commit

Permalink
Initial implementation of VercelKV apl (#321)
Browse files Browse the repository at this point in the history
  • Loading branch information
lkostrowski authored Dec 6, 2023
1 parent 9a5c858 commit f49c63f
Show file tree
Hide file tree
Showing 8 changed files with 249 additions and 3 deletions.
17 changes: 17 additions & 0 deletions .changeset/nine-hotels-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@saleor/app-sdk": minor
---

Added VercelKvApl. It will use [Vercel KV Storage](https://vercel.com/docs/storage/vercel-kv) (using Upstash Redis under the hood).

APL requires following env variables:

`KV_URL`,`KV_REST_API_URL`,`KV_REST_API_TOKEN`,`KV_REST_API_READ_ONLY_TOKEN` - KV variables that are automatically linked by Vercel when KV is attached to the project.

`KV_STORAGE_NAMESPACE` - a string identifier that should be unique per app. If more than one app writes with the same `KV_STORAGE_NAMESPACE`, Auth Data will be overwritten and apps can stop working.

For now experimental - can be imported with:

```
import { _experimental_VercelKvApl } from "@saleor/app-sdk/apl";
```
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

pnpx lint-staged
pnpm run lint-staged
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"prepare": "husky install",
"lint": "tsc --noEmit && prettier --loglevel warn --write . && eslint --fix .",
"copy-readme": "cp README.md dist/README.md",
"publish:ci": "pnpm publish && npx changeset tag && git push --follow-tags"
"publish:ci": "pnpm publish && npx changeset tag && git push --follow-tags",
"lint-staged": "lint-staged"
},
"keywords": [],
"author": "",
Expand All @@ -21,7 +22,8 @@
"graphql": ">=16.6.0",
"next": ">=12",
"react": ">=17",
"react-dom": ">=17"
"react-dom": ">=17",
"@vercel/kv": "^1.0.0"
},
"dependencies": {
"@opentelemetry/api": "^1.7.0",
Expand All @@ -43,6 +45,7 @@
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.36.1",
"@typescript-eslint/parser": "^5.36.1",
"@vercel/kv": "1.0.0",
"@vitejs/plugin-react": "^3.0.1",
"@vitest/coverage-c8": "^0.27.2",
"clean-publish": "^4.0.1",
Expand Down Expand Up @@ -71,6 +74,11 @@
"vite": "^4.0.4",
"vitest": "^0.28.1"
},
"peerDependenciesMeta": {
"@vercel/kv": {
"optional": true
}
},
"lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix",
"*.{js,ts,tsx,css,md,json}": "prettier --write"
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/APL/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from "./env-apl";
export * from "./file-apl";
export * from "./saleor-cloud";
export * from "./upstash-apl";
// eslint-disable-next-line camelcase
export { VercelKvApl as _experimental_VercelKvApl } from "./vercel-kv";
1 change: 1 addition & 0 deletions src/APL/vercel-kv/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./vercel-kv-apl";
75 changes: 75 additions & 0 deletions src/APL/vercel-kv/vercel-kv-apl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { kv, VercelKV } from "@vercel/kv";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";

import { AuthData } from "../apl";
import { VercelKvApl } from "./vercel-kv-apl";

vi.mock("@vercel/kv", () => {
/**
* Client uses only hash methods
*/
const mockKv: Pick<VercelKV, "hget" | "hset" | "hdel" | "hgetall"> = {
hget: vi.fn(),
hset: vi.fn(),
hdel: vi.fn(),
hgetall: vi.fn(),
};

return { kv: mockKv };
});

const getMockAuthData = (saleorApiUrl = "https://demo.saleor.io/graphql"): AuthData => ({
appId: "foobar",
saleorApiUrl,
token: "token",
domain: "domain",
jwks: "{}",
});

const getMockAuthDataString = () => JSON.stringify(getMockAuthData());

const APP_NAME_NAMESPACE = "test-app";

describe("VercelKvApl", () => {
beforeEach(() => {
vi.stubEnv("KV_URL", "https://url.vercel.io");
vi.stubEnv("KV_REST_API_URL", "https://url.vercel.io");
vi.stubEnv("KV_REST_API_TOKEN", "test-token");
vi.stubEnv("KV_REST_API_READ_ONLY_TOKEN", "test-read-token");
vi.stubEnv("KV_STORAGE_NAMESPACE", APP_NAME_NAMESPACE);
});

it("Constructs", () => {
expect(new VercelKvApl()).toBeDefined();
});

it("Fails if envs are missing", () => {
vi.unstubAllEnvs();

expect(() => new VercelKvApl()).toThrow();
});

describe("get", () => {
it("returns parsed auth data", async () => {
(kv.hget as Mock).mockImplementationOnce(async () => getMockAuthDataString());

const apl = new VercelKvApl();

const authData = await apl.get("https://demo.saleor.io/graphql");

expect(authData).toEqual(getMockAuthData());
});
});

describe("set", () => {
it("Sets auth data under a namespace provided in env", async () => {
const apl = new VercelKvApl();

await apl.set(getMockAuthData());

expect(kv.hset).toHaveBeenCalledWith(APP_NAME_NAMESPACE, {
"https://demo.saleor.io/graphql": getMockAuthDataString(),
});
});
});
});
123 changes: 123 additions & 0 deletions src/APL/vercel-kv/vercel-kv-apl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { kv } from "@vercel/kv";

import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "../apl";
import { createAPLDebug } from "../apl-debug";

type Params = {
hashCollectionKey?: string;
};

export class VercelKvApl implements APL {
private debug = createAPLDebug("VercelKvApl");

/**
* Store all items inside hash collection, to enable read ALL items when needed.
* Otherwise, multiple redis calls will be needed to iterate over every key.
*
* To allow connecting many apps to single KV storage, we need to use different hash collection key for each app.
*/
private hashCollectionKey = process.env.KV_STORAGE_NAMESPACE as string;

constructor(options?: Params) {
if (!this.envVariablesRequiredByKvExist()) {
throw new Error("Missing KV env variables, please link KV storage to your project");
}

this.hashCollectionKey = options?.hashCollectionKey ?? this.hashCollectionKey;
}

async get(saleorApiUrl: string): Promise<AuthData | undefined> {
this.debug("Will call Vercel KV to get auth data for %s", saleorApiUrl);

try {
const authData = await kv.hget<string>(this.hashCollectionKey, saleorApiUrl);

return authData ? (JSON.parse(authData) as AuthData) : undefined;
} catch (e) {
this.debug("Failed to get auth data from Vercel KV");
this.debug(e);

throw e;
}
}

async set(authData: AuthData): Promise<void> {
this.debug("Will call Vercel KV to set auth data for %s", authData.saleorApiUrl);

try {
await kv.hset(this.hashCollectionKey, {
[authData.saleorApiUrl]: JSON.stringify(authData),
});
} catch (e) {
this.debug("Failed to set auth data in Vercel KV");
this.debug(e);

throw e;
}
}

async delete(saleorApiUrl: string) {
this.debug("Will call Vercel KV to delete auth data for %s", saleorApiUrl);

try {
await kv.hdel(this.hashCollectionKey, saleorApiUrl);
} catch (e) {
this.debug("Failed to delete auth data from Vercel KV");
this.debug(e);

throw e;
}
}

async getAll() {
const results = await kv.hgetall<Record<string, string>>(this.hashCollectionKey);

if (results === null) {
throw new Error("Missing KV collection, data was never written");
}

return Object.values(results).map((item) => {
const authData = JSON.parse(item) as AuthData;

return authData;
});
}

async isReady(): Promise<AplReadyResult> {
const ready = this.envVariablesRequiredByKvExist();

return ready
? {
ready: true,
}
: {
ready: false,
error: new Error("Missing KV env variables, please link KV storage to your project"),
};
}

async isConfigured(): Promise<AplConfiguredResult> {
const configured = this.envVariablesRequiredByKvExist();

return configured
? {
configured: true,
}
: {
configured: false,
error: new Error("Missing KV env variables, please link KV storage to your project"),
};
}

private envVariablesRequiredByKvExist() {
const variables = [
"KV_URL",
"KV_REST_API_URL",
"KV_REST_API_TOKEN",
"KV_REST_API_READ_ONLY_TOKEN",
"KV_STORAGE_NAMESPACE",
];

return variables.every((variable) => !!process.env[variable]);
}
}

0 comments on commit f49c63f

Please sign in to comment.