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",
|
"import": "./APL/index.mjs",
|
||||||
"require": "./APL/index.js"
|
"require": "./APL/index.js"
|
||||||
},
|
},
|
||||||
|
"./settings-manager": {
|
||||||
|
"types": "./settings-manager/index.d.ts",
|
||||||
|
"import": "./settings-manager/index.mjs",
|
||||||
|
"require": "./settings-manager/index.js"
|
||||||
|
},
|
||||||
"./middleware": {
|
"./middleware": {
|
||||||
"types": "./middleware/index.d.ts",
|
"types": "./middleware/index.d.ts",
|
||||||
"import": "./middleware/index.mjs",
|
"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/app-bridge/index.ts",
|
||||||
"src/handlers/next/index.ts",
|
"src/handlers/next/index.ts",
|
||||||
"src/middleware/index.ts",
|
"src/middleware/index.ts",
|
||||||
|
"src/settings-manager/index.ts",
|
||||||
],
|
],
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
|
|
Loading…
Reference in a new issue