-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial implementation of VercelKV apl (#321)
- Loading branch information
1 parent
9a5c858
commit f49c63f
Showing
8 changed files
with
249 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./vercel-kv-apl"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
} | ||
} |