Skip to content

Commit

Permalink
feat(cloudflare-kv): support base option for keys (#261)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 authored Jul 4, 2023
1 parent cb8577b commit 34f14f8
Show file tree
Hide file tree
Showing 6 changed files with 97 additions and 18 deletions.
1 change: 1 addition & 0 deletions docs/content/6.drivers/cloudflare-kv-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ const storage = createStorage({
**Options:**

- `binding`: KV binding or name of namespace. Default is `STORAGE`.
- `base`: Adds prefix to all stored keys
1 change: 1 addition & 0 deletions docs/content/6.drivers/cloudflare-kv-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const storage = createStorage({
- `apiKey`: API key generated on the "My Account" page of the Cloudflare console. May be used along with `email` to authenticate in place of `apiToken`.
- `userServiceKey`: A special Cloudflare API key good for a restricted set of endpoints. Always begins with "v1.0-", may vary in length. May be used to authenticate in place of `apiToken` or `apiKey` and `email`.
- `apiURL`: Custom API URL. Default is `https://api.cloudflare.com`.
- `base`: Adds prefix to all stored keys

**Supported methods:**

Expand Down
25 changes: 19 additions & 6 deletions src/drivers/cloudflare-kv-binding.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
/// <reference types="@cloudflare/workers-types" />
import { createError, defineDriver } from "./utils";
import { createError, defineDriver, joinKeys } from "./utils";
export interface KVOptions {
binding?: string | KVNamespace;

/** Adds prefix to all stored keys */
base?: string;
}

// https://developers.cloudflare.com/workers/runtime-apis/kv

const DRIVER_NAME = "cloudflare-kv-binding";

export default defineDriver((opts: KVOptions = {}) => {
async function getKeys(base?: string) {
const r = (key: string = "") => (opts.base ? joinKeys(opts.base, key) : key);

async function getKeys(base: string = "") {
base = r(base);
const binding = getBinding(opts.binding);
const kvList = await binding.list(base ? { prefix: base } : undefined);
return kvList.keys.map((key) => key.name);
Expand All @@ -19,26 +25,33 @@ export default defineDriver((opts: KVOptions = {}) => {
name: DRIVER_NAME,
options: opts,
async hasItem(key) {
key = r(key);
const binding = getBinding(opts.binding);
return (await binding.get(key)) !== null;
},
getItem(key) {
key = r(key);
const binding = getBinding(opts.binding);
return binding.get(key);
},
setItem(key, value) {
key = r(key);
const binding = getBinding(opts.binding);
return binding.put(key, value);
},
removeItem(key) {
key = r(key);
const binding = getBinding(opts.binding);
return binding.delete(key);
},
// TODO: use this.getKeys once core is fixed
getKeys,
async clear() {
getKeys() {
return getKeys().then((keys) =>
keys.map((key) => (opts.base ? key.slice(opts.base.length) : key))
);
},
async clear(base) {
const binding = getBinding(opts.binding);
const keys = await getKeys();
const keys = await getKeys(base);
await Promise.all(keys.map((key) => binding.delete(key)));
},
};
Expand Down
30 changes: 22 additions & 8 deletions src/drivers/cloudflare-kv-http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { $fetch } from "ofetch";
import { createError, createRequiredError, defineDriver } from "./utils";
import {
createError,
createRequiredError,
defineDriver,
joinKeys,
} from "./utils";

interface KVAuthAPIToken {
/**
Expand Down Expand Up @@ -48,6 +53,10 @@ export type KVHTTPOptions = {
* @default https://api.cloudflare.com
*/
apiURL?: string;
/**
* Adds prefix to all stored keys
*/
base?: string;
} & (KVAuthServiceKey | KVAuthAPIToken | KVAuthEmailKey);

type CloudflareAuthorizationHeaders =
Expand Down Expand Up @@ -99,9 +108,11 @@ export default defineDriver<KVHTTPOptions>((opts) => {
const baseURL = `${apiURL}/client/v4/accounts/${opts.accountId}/storage/kv/namespaces/${opts.namespaceId}`;
const kvFetch = $fetch.create({ baseURL, headers });

const r = (key: string = "") => (opts.base ? joinKeys(opts.base, key) : key);

const hasItem = async (key: string) => {
try {
const res = await kvFetch(`/metadata/${key}`);
const res = await kvFetch(`/metadata/${r(key)}`);
return res?.success === true;
} catch (err: any) {
if (!err?.response) {
Expand All @@ -117,7 +128,7 @@ export default defineDriver<KVHTTPOptions>((opts) => {
const getItem = async (key: string) => {
try {
// Cloudflare API returns with `content-type: application/octet-stream`
return await kvFetch(`/values/${key}`).then((r) => r.text());
return await kvFetch(`/values/${r(key)}`).then((r) => r.text());
} catch (err: any) {
if (!err?.response) {
throw err;
Expand All @@ -130,19 +141,19 @@ export default defineDriver<KVHTTPOptions>((opts) => {
};

const setItem = async (key: string, value: any) => {
return await kvFetch(`/values/${key}`, { method: "PUT", body: value });
return await kvFetch(`/values/${r(key)}`, { method: "PUT", body: value });
};

const removeItem = async (key: string) => {
return await kvFetch(`/values/${key}`, { method: "DELETE" });
return await kvFetch(`/values/${r(key)}`, { method: "DELETE" });
};

const getKeys = async (base?: string) => {
const keys: string[] = [];

const params: Record<string, string | undefined> = {};
if (base) {
params.prefix = base;
if (base || opts.base) {
params.prefix = r(base);
}

const firstPage = await kvFetch("/keys", { params });
Expand Down Expand Up @@ -199,7 +210,10 @@ export default defineDriver<KVHTTPOptions>((opts) => {
getItem,
setItem,
removeItem,
getKeys,
getKeys: (base?: string) =>
getKeys(base).then((keys) =>
keys.map((key) => (opts.base ? key.slice(opts.base.length) : key))
),
clear,
};
});
32 changes: 29 additions & 3 deletions test/drivers/cloudflare-kv-binding.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="@cloudflare/workers-types" />
import { describe } from "vitest";
import { createStorage } from "../../src";
import { describe, expect, test } from "vitest";
import { createStorage, snapshot } from "../../src";
import CloudflareKVBinding from "../../src/drivers/cloudflare-kv-binding";
import { testDriver } from "./utils";

Expand Down Expand Up @@ -29,6 +29,32 @@ const mockBinding: KVNamespace = {

describe("drivers: cloudflare-kv", () => {
testDriver({
driver: CloudflareKVBinding({ binding: mockBinding }),
driver: CloudflareKVBinding({ binding: mockBinding, base: "base" }),
async additionalTests(ctx) {
test("snapshot", async () => {
expect(await snapshot(mockStorage, "")).toMatchInlineSnapshot(`
{
"base:data:raw.bin": "base64:AQID",
"base:data:serialized1.json": "SERIALIZED",
"base:data:serialized2.json": {
"serializedObj": "works",
},
"base:data:test.json": {
"json": "works",
},
"base:data:true.json": true,
"base:s1:a": "test_data",
"base:s2:a": "test_data",
"base:s3:a": "test_data",
"base:t:1": "test_data_t1",
"base:t:2": "test_data_t2",
"base:t:3": "test_data_t3",
"base:v1:a": "test_data_v1:a",
"base:v2:a": "test_data_v2:a",
"base:v3:a": "test_data_v3:a?q=1",
}
`);
});
},
});
});
26 changes: 25 additions & 1 deletion test/drivers/cloudflare-kv-http.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { afterAll, beforeAll, describe } from "vitest";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import driver, { KVHTTPOptions } from "../../src/drivers/cloudflare-kv-http";
import { testDriver } from "./utils";
import { rest } from "msw";
import { setupServer } from "msw/node";
import { snapshot } from "../../src";

const baseURL =
"https://api.cloudflare.com/client/v4/accounts/:accountId/storage/kv/namespaces/:namespaceId";
Expand Down Expand Up @@ -71,6 +72,7 @@ const server = setupServer(
);

const mockOptions: KVHTTPOptions = {
base: "base",
apiToken: "api-token",
accountId: "account-id",
namespaceId: "namespace-id",
Expand All @@ -91,5 +93,27 @@ describe.skipIf(isNode18)("drivers: cloudflare-kv-http", () => {

testDriver({
driver: driver(mockOptions),
async additionalTests(ctx) {
test("snapshot", async () => {
expect(store).toMatchInlineSnapshot(`
{
"base:data:raw.bin": "base64:AQID",
"base:data:serialized1.json": "SERIALIZED",
"base:data:serialized2.json": "{\\"serializedObj\\":\\"works\\"}",
"base:data:test.json": "{\\"json\\":\\"works\\"}",
"base:data:true.json": "true",
"base:s1:a": "test_data",
"base:s2:a": "test_data",
"base:s3:a": "test_data",
"base:t:1": "test_data_t1",
"base:t:2": "test_data_t2",
"base:t:3": "test_data_t3",
"base:v1:a": "test_data_v1:a",
"base:v2:a": "test_data_v2:a",
"base:v3:a": "test_data_v3:a?q=1",
}
`);
});
},
});
});

0 comments on commit 34f14f8

Please sign in to comment.