parent
dee16cea40
commit
be79af2d63
7 changed files with 223 additions and 0 deletions
68
docs/settings-manager.md
Normal file
68
docs/settings-manager.md
Normal 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),
|
||||
});
|
||||
```
|
|
@ -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",
|
||||
|
|
2
src/settings-manager/index.ts
Normal file
2
src/settings-manager/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from "./metadata-manager";
|
||||
export * from "./settings-manager";
|
57
src/settings-manager/metadata-manager.test.ts
Normal file
57
src/settings-manager/metadata-manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
80
src/settings-manager/metadata-manager.ts
Normal file
80
src/settings-manager/metadata-manager.ts
Normal 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);
|
||||
}
|
||||
}
|
10
src/settings-manager/settings-manager.ts
Normal file
10
src/settings-manager/settings-manager.ts
Normal 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>;
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue