Add metadata manager (#73)

* Add metadata manager
This commit is contained in:
Krzysztof Wolski 2022-10-12 13:28:00 +02:00 committed by GitHub
parent dee16cea40
commit be79af2d63
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 223 additions and 0 deletions

68
docs/settings-manager.md Normal file
View file

@ -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<string | undefined>`
- `set: (settings: SettingsValue[] | SettingsValue) => Promise<void>`
# 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<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(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),
});
```

View file

@ -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",

View file

@ -0,0 +1,2 @@
export * from "./metadata-manager";
export * from "./settings-manager";

View file

@ -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);
});
});
});

View file

@ -0,0 +1,80 @@
import { SettingsManager, SettingsValue } from "./settings-manager";
export type MetadataEntry = {
key: string;
value: string;
};
export type FetchMetadataCallback = () => Promise<MetadataEntry[]>;
export type MutateMetadataCallback = (metadata: MetadataEntry[]) => Promise<MetadataEntry[]>;
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);
}
}

View file

@ -0,0 +1,10 @@
export type SettingsValue = {
key: string;
value: string;
domain?: string;
};
export interface SettingsManager {
get: (key: string, domain?: string) => Promise<string | undefined>;
set: (settings: SettingsValue[] | SettingsValue) => Promise<void>;
}

View file

@ -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,