From be79af2d637dec38f2f43ae859ab5ed40115f8b9 Mon Sep 17 00:00:00 2001 From: Krzysztof Wolski Date: Wed, 12 Oct 2022 13:28:00 +0200 Subject: [PATCH] Add metadata manager (#73) * Add metadata manager --- docs/settings-manager.md | 68 ++++++++++++++++ package.json | 5 ++ src/settings-manager/index.ts | 2 + src/settings-manager/metadata-manager.test.ts | 57 +++++++++++++ src/settings-manager/metadata-manager.ts | 80 +++++++++++++++++++ src/settings-manager/settings-manager.ts | 10 +++ tsup.config.ts | 1 + 7 files changed, 223 insertions(+) create mode 100644 docs/settings-manager.md create mode 100644 src/settings-manager/index.ts create mode 100644 src/settings-manager/metadata-manager.test.ts create mode 100644 src/settings-manager/metadata-manager.ts create mode 100644 src/settings-manager/settings-manager.ts diff --git a/docs/settings-manager.md b/docs/settings-manager.md new file mode 100644 index 0000000..94f8449 --- /dev/null +++ b/docs/settings-manager.md @@ -0,0 +1,68 @@ +# Settings Manager + +Settings managers are used to persist configuration data like API keys, preferences, etc.. + +## `SettingsValue` interface + +Entries in the manager are stored using structure: + +``` + key: string; + value: string; + domain?: string; +``` + +For values which should not be migrated during environment cloning (as private keys to payment provider), developer should use domain field to bind it to particular store instance. + +## Available methods + +- `get: (key: string, domain?: string) => Promise` +- `set: (settings: SettingsValue[] | SettingsValue) => Promise` + +# MetadataManager + +Default manager used by app template. Use app metadata as storage. Since app developer can use any GraphQL client, constructor must be parametrized with fetch and mutate functions: + +```ts +import { MetadataEntry } from "@saleor/app-sdk/settings-manager"; +import { Client } from "urql"; + +import { + FetchAppDetailsDocument, + FetchAppDetailsQuery, + UpdateAppMetadataDocument, +} from "../generated/graphql"; + +export async function fetchAllMetadata(client: Client): Promise { + const { error, data } = await client + .query(FetchAppDetailsDocument, {}) + .toPromise(); + + return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || []; +} + +export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) { + const { error: mutationError, data: mutationData } = await client + .mutation(UpdateAppMetadataDocument, { + id: appId, + input: metadata, + }) + .toPromise(); + + return ( + mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({ + key: md.key, + value: md.value, + })) || [] + ); +} +``` + +And create MetadataManager instance: + +```ts +const settings = new MetadataManager({ + fetchMetadata: () => fetchAllMetadata(client), + mutateMetadata: (md) => mutateMetadata(client, md), +}); +``` diff --git a/package.json b/package.json index 5c378e8..c359f2f 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,11 @@ "import": "./APL/index.mjs", "require": "./APL/index.js" }, + "./settings-manager": { + "types": "./settings-manager/index.d.ts", + "import": "./settings-manager/index.mjs", + "require": "./settings-manager/index.js" + }, "./middleware": { "types": "./middleware/index.d.ts", "import": "./middleware/index.mjs", diff --git a/src/settings-manager/index.ts b/src/settings-manager/index.ts new file mode 100644 index 0000000..494eaa5 --- /dev/null +++ b/src/settings-manager/index.ts @@ -0,0 +1,2 @@ +export * from "./metadata-manager"; +export * from "./settings-manager"; diff --git a/src/settings-manager/metadata-manager.test.ts b/src/settings-manager/metadata-manager.test.ts new file mode 100644 index 0000000..9713e98 --- /dev/null +++ b/src/settings-manager/metadata-manager.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { MetadataEntry, MetadataManager } from "./metadata-manager"; + +const initialEntry = { key: "a", value: "without domain" }; + +const entryForDomainX = { key: "a__x.com", value: "domain x value" }; +const entryForDomainY = { key: "a__y.com", value: "domain y value" }; + +const metadata = [initialEntry, entryForDomainX, entryForDomainY]; + +describe("settings-manager", () => { + describe("metadata-manager", () => { + const fetchMock = vi.fn(async () => metadata); + const mutateMock = vi.fn(async (md: MetadataEntry[]) => [...metadata, ...md]); + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("Get method - using cached values", async () => { + const manager = new MetadataManager({ fetchMetadata: fetchMock, mutateMetadata: mutateMock }); + expect(fetchMock).toBeCalledTimes(0); + + // Fetch should be called just after getting a first value + expect(await manager.get("a")).toBe(initialEntry.value); + expect(fetchMock).toBeCalledTimes(1); + + // Calling get method second time should use cached values and not call fetch a second time + expect(await manager.get("a")).toBe(initialEntry.value); + expect(fetchMock).toBeCalledTimes(1); + + // Calling get method with different values should also use the cache, since API returns all metadata ot once + expect(await manager.get("unknown")).toBe(undefined); + expect(fetchMock).toBeCalledTimes(1); + }); + + it("Get method - return values for chosen domain", async () => { + const manager = new MetadataManager({ fetchMetadata: fetchMock, mutateMetadata: mutateMock }); + + expect(await manager.get("a", "x.com")).toBe(entryForDomainX.value); + expect(await manager.get("a", "y.com")).toBe(entryForDomainY.value); + expect(await manager.get("a", "unknown.com")).toBe(undefined); + }); + + it("Set method - return values for chosen domain", async () => { + const manager = new MetadataManager({ fetchMetadata: fetchMock, mutateMetadata: mutateMock }); + const newEntry = { key: "new", value: "new value" }; + + await manager.set(newEntry); + expect(await manager.get(newEntry.key)).toBe(newEntry.value); + expect(mutateMock).toBeCalledTimes(1); + // Set method should populate cache with updated values, so fetch is never called + expect(fetchMock).toBeCalledTimes(0); + }); + }); +}); diff --git a/src/settings-manager/metadata-manager.ts b/src/settings-manager/metadata-manager.ts new file mode 100644 index 0000000..1ee498b --- /dev/null +++ b/src/settings-manager/metadata-manager.ts @@ -0,0 +1,80 @@ +import { SettingsManager, SettingsValue } from "./settings-manager"; + +export type MetadataEntry = { + key: string; + value: string; +}; + +export type FetchMetadataCallback = () => Promise; + +export type MutateMetadataCallback = (metadata: MetadataEntry[]) => Promise; + +const deserializeMetadata = ({ key, value }: MetadataEntry): SettingsValue => { + // domain specific metadata use convention key__domain, e.g. `secret_key__example.com` + const [newKey, domain] = key.split("__"); + + return { + key: newKey, + domain, + value, + }; +}; + +const serializeSettingsToMetadata = ({ key, value, domain }: SettingsValue): MetadataEntry => { + // domain specific metadata use convention key__domain, e.g. `secret_key__example.com` + if (!domain) { + return { key, value }; + } + + return { + key: [key, domain].join("__"), + value, + }; +}; + +interface MetadataManagerConfig { + fetchMetadata: FetchMetadataCallback; + mutateMetadata: MutateMetadataCallback; +} + +/** + * Metadata Manager use app metadata to store settings. + * To minimize network calls, once fetched metadata are cached. + * Cache invalidation occurs if any value is set. + * + * + */ +export class MetadataManager implements SettingsManager { + private settings: SettingsValue[] = []; + + private fetchMetadata: FetchMetadataCallback; + + private mutateMetadata: MutateMetadataCallback; + + constructor({ fetchMetadata, mutateMetadata }: MetadataManagerConfig) { + this.fetchMetadata = fetchMetadata; + this.mutateMetadata = mutateMetadata; + } + + async get(key: string, domain?: string) { + if (!this.settings.length) { + const metadata = await this.fetchMetadata(); + this.settings = metadata.map(deserializeMetadata); + } + + const setting = this.settings.find((md) => md.key === key && md.domain === domain); + return setting?.value; + } + + async set(settings: SettingsValue[] | SettingsValue) { + let serializedMetadata = []; + if (Array.isArray(settings)) { + serializedMetadata = settings.map(serializeSettingsToMetadata); + } else { + serializedMetadata = [serializeSettingsToMetadata(settings)]; + } + // changes should update cache + const metadata = await this.mutateMetadata(serializedMetadata); + this.settings = metadata.map(deserializeMetadata); + } +} diff --git a/src/settings-manager/settings-manager.ts b/src/settings-manager/settings-manager.ts new file mode 100644 index 0000000..c2a2644 --- /dev/null +++ b/src/settings-manager/settings-manager.ts @@ -0,0 +1,10 @@ +export type SettingsValue = { + key: string; + value: string; + domain?: string; +}; + +export interface SettingsManager { + get: (key: string, domain?: string) => Promise; + set: (settings: SettingsValue[] | SettingsValue) => Promise; +} diff --git a/tsup.config.ts b/tsup.config.ts index 015ced5..a457961 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "src/app-bridge/index.ts", "src/handlers/next/index.ts", "src/middleware/index.ts", + "src/settings-manager/index.ts", ], dts: true, clean: true,