Add delete method do settings manager and implement it metadata manager (#241)

* Add delete method do settings manager and implement it metadata manager

* Implement EncrypedMetadataManager.delete + changesets + docs

* Update src/settings-manager/metadata-manager.test.ts

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>

* Fix test

---------

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
This commit is contained in:
Lukasz Ostrowski 2023-05-21 18:42:35 +02:00 committed by GitHub
parent 4e3bddabdd
commit d4169dc35a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 199 additions and 5 deletions

View file

@ -0,0 +1,5 @@
---
"@saleor/app-sdk": minor
---
Added new method to SettingsManager - "delete". It can delete metadata by key in Saleor. Implemented in MetadataManager and EncryptedMetadataManager

View file

@ -12,12 +12,28 @@ Entries in the manager are stored using structure:
domain?: string;
```
## `DeleteMetadataArg` interface
Argument that can be used to remove metadata via `manager.delete()` method
It contains key and domain:
```typescript
type DeleteMetadataArg = {
key: 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>`
- `delete: (args: string | string[] | DeleteMetadataArg | DeleteMetadataArg[]) => Promise<void>`
Warning: delete method can throw, if instance of SettingsManager was not configured with proper mutation in constructor.
# MetadataManager
@ -31,6 +47,7 @@ import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
DeleteMetadataDocument,
} from "../generated/graphql";
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
@ -56,6 +73,10 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[])
})) || []
);
}
export async function deleteMetadata(client: Client, keys: string[]) {
return client.mutation(DeleteMetadataDocument, { keys }).toPromise();
}
```
And create MetadataManager instance:
@ -64,6 +85,7 @@ And create MetadataManager instance:
const settings = new MetadataManager({
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (md) => mutateMetadata(client, md),
deleteMetadata: (keys) => deleteMetadata(client, keys),
});
```
@ -77,6 +99,7 @@ new EncryptedMetadataManager({
encryptionKey: process.env.SECRET_KEY, // secrets should be saved in the environment variables, never in the source code
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
deleteMetadata: (keys) => deleteMetadata(client, keys),
});
```

View file

@ -62,6 +62,8 @@ describe("settings-manager", () => {
});
describe("Manager operations", () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
const deleteMetadataMock = vi.fn(async () => {});
const fetchMock = vi.fn(async () => metadata);
const mutateMock = vi.fn(async (md: MetadataEntry[]) => [...metadata, ...md]);
const manager = new EncryptedMetadataManager({
@ -122,6 +124,48 @@ describe("settings-manager", () => {
// Set method should populate cache with updated values, so fetch is never called
expect(fetchMock).toBeCalledTimes(0);
});
/**
* Smoke test operations for "delete" method.
* Details tests are in MetadataManager that is under the hood of EncryptedMetadataManager
*/
it("Calls delete metadata mutation when key deleted - all formats", async () => {
const managerWithDelete = new EncryptedMetadataManager({
fetchMetadata: fetchMock,
mutateMetadata: mutateMock,
deleteMetadata: deleteMetadataMock,
encryptionKey: "key",
});
await managerWithDelete.delete("single-arg-1");
await managerWithDelete.delete(["multiple-arg-1", "multiple-arg-2"]);
await managerWithDelete.delete({
key: "single-arg-1-domain",
domain: "https://example.com/graphql/",
});
await managerWithDelete.delete([
{
key: "multiple-arg-1-domain",
domain: "https://example.com/graphql/",
},
{
key: "multiple-arg-2-domain",
domain: "https://example.com/graphql/",
},
]);
expect(deleteMetadataMock).toBeCalledTimes(4);
expect(deleteMetadataMock).toHaveBeenNthCalledWith(1, ["single-arg-1"]);
expect(deleteMetadataMock).toHaveBeenNthCalledWith(2, ["multiple-arg-1", "multiple-arg-2"]);
expect(deleteMetadataMock).toHaveBeenNthCalledWith(3, [
"single-arg-1-domain__https://example.com/graphql/",
]);
expect(deleteMetadataMock).toHaveBeenNthCalledWith(4, [
"multiple-arg-1-domain__https://example.com/graphql/",
"multiple-arg-2-domain__https://example.com/graphql/",
]);
});
});
});
});

View file

@ -1,7 +1,7 @@
import crypto from "crypto";
import { MetadataManager, MetadataManagerConfig } from "./metadata-manager";
import { SettingsManager, SettingsValue } from "./settings-manager";
import { DeleteSettingsValue, SettingsManager, SettingsValue } from "./settings-manager";
export type EncryptCallback = (value: string, secret: string) => string;
@ -66,10 +66,12 @@ export class EncryptedMetadataManager implements SettingsManager {
encryptionKey,
encryptionMethod,
decryptionMethod,
deleteMetadata,
}: EncryptedMetadataManagerConfig) {
this.metadataManager = new MetadataManager({
fetchMetadata,
mutateMetadata,
deleteMetadata,
});
if (encryptionKey) {
this.encryptionKey = encryptionKey;
@ -110,4 +112,8 @@ export class EncryptedMetadataManager implements SettingsManager {
}));
return this.metadataManager.set(encryptedSettings);
}
async delete(args: DeleteSettingsValue | DeleteSettingsValue[] | string | string[]) {
await this.metadataManager.delete(args);
}
}

View file

@ -13,6 +13,8 @@ describe("settings-manager", () => {
describe("metadata-manager", () => {
const fetchMock = vi.fn(async () => metadata);
const mutateMock = vi.fn(async (md: MetadataEntry[]) => [...metadata, ...md]);
// eslint-disable-next-line @typescript-eslint/no-empty-function
const deleteMetadataMock = vi.fn(async () => {});
beforeEach(() => {
vi.restoreAllMocks();
@ -53,5 +55,71 @@ describe("settings-manager", () => {
// Set method should populate cache with updated values, so fetch is never called
expect(fetchMock).toBeCalledTimes(0);
});
describe("Delete metadata method", () => {
/**
* Ensure no breaking changes introduced
*/
it("Constructs if deleteMetadata param is not passed", () => {
const manager = new MetadataManager({
fetchMetadata: fetchMock,
mutateMetadata: mutateMock,
});
expect(manager).toBeDefined();
});
it("Throws if \"delete\" method is called, but deleteMetadata was not passed to constructor", async () => {
const manager = new MetadataManager({
fetchMetadata: fetchMock,
mutateMetadata: mutateMock,
});
await expect(manager.delete("test")).rejects.toThrowErrorMatchingInlineSnapshot(
"\"Delete not implemented. Ensure MetadataManager is configured with deleteMetadata param in constructor\""
);
});
it("Calls deleteMetadata constructor param when \"delete\" method called", async () => {
const manager = new MetadataManager({
fetchMetadata: fetchMock,
mutateMetadata: mutateMock,
deleteMetadata: deleteMetadataMock,
});
await manager.delete("single-key");
await manager.delete(["multiple-key-1", "multiple-key-2"]);
expect(deleteMetadataMock).toBeCalledTimes(2);
/**
* Ensure callback is called with array type, even if single string was passed
*/
expect(deleteMetadataMock).toHaveBeenNthCalledWith(1, ["single-key"]);
expect(deleteMetadataMock).toHaveBeenNthCalledWith(2, ["multiple-key-1", "multiple-key-2"]);
});
it("Accepts records with key and domain and constructs scoped suffixes", async () => {
const manager = new MetadataManager({
fetchMetadata: fetchMock,
mutateMetadata: mutateMock,
deleteMetadata: deleteMetadataMock,
});
await manager.delete({ key: "test", domain: "https://example.com/graphql/" });
await manager.delete([
{ key: "test1", domain: "https://example.com/graphql/" },
{ key: "test2", domain: "https://example.com/graphql/" },
]);
expect(deleteMetadataMock).toBeCalledTimes(2);
expect(deleteMetadataMock).toHaveBeenNthCalledWith(1, [
"test__https://example.com/graphql/",
]);
expect(deleteMetadataMock).toHaveBeenNthCalledWith(2, [
"test1__https://example.com/graphql/",
"test2__https://example.com/graphql/",
]);
});
});
});
});

View file

@ -1,13 +1,16 @@
import { SettingsManager, SettingsValue } from "./settings-manager";
import { DeleteSettingsValue, SettingsManager, SettingsValue } from "./settings-manager";
export type MetadataEntry = {
key: string;
value: string;
};
/**
* TODO Rename "Callback" suffixes, these are not callbacks
*/
export type FetchMetadataCallback = () => Promise<MetadataEntry[]>;
export type MutateMetadataCallback = (metadata: MetadataEntry[]) => Promise<MetadataEntry[]>;
export type DeleteMetadataCallback = (keys: string[]) => Promise<void>;
const deserializeMetadata = ({ key, value }: MetadataEntry): SettingsValue => {
// domain specific metadata use convention key__domain, e.g. `secret_key__example.com`
@ -20,6 +23,8 @@ const deserializeMetadata = ({ key, value }: MetadataEntry): SettingsValue => {
};
};
const constructDomainSpecificKey = (key: string, domain: string) => [key, domain].join("__");
const serializeSettingsToMetadata = ({ key, value, domain }: SettingsValue): MetadataEntry => {
// domain specific metadata use convention key__domain, e.g. `secret_key__example.com`
if (!domain) {
@ -27,7 +32,7 @@ const serializeSettingsToMetadata = ({ key, value, domain }: SettingsValue): Met
}
return {
key: [key, domain].join("__"),
key: constructDomainSpecificKey(key, domain),
value,
};
};
@ -35,6 +40,11 @@ const serializeSettingsToMetadata = ({ key, value, domain }: SettingsValue): Met
export interface MetadataManagerConfig {
fetchMetadata: FetchMetadataCallback;
mutateMetadata: MutateMetadataCallback;
/**
* Keep it optional, to avoid breaking changes. If not provided, delete will throw.
* TODO: Make it required in next major version
*/
deleteMetadata?: DeleteMetadataCallback;
}
/**
@ -51,9 +61,12 @@ export class MetadataManager implements SettingsManager {
private mutateMetadata: MutateMetadataCallback;
constructor({ fetchMetadata, mutateMetadata }: MetadataManagerConfig) {
private deleteMetadata?: DeleteMetadataCallback;
constructor({ fetchMetadata, mutateMetadata, deleteMetadata }: MetadataManagerConfig) {
this.fetchMetadata = fetchMetadata;
this.mutateMetadata = mutateMetadata;
this.deleteMetadata = deleteMetadata;
}
async get(key: string, domain?: string) {
@ -77,4 +90,28 @@ export class MetadataManager implements SettingsManager {
const metadata = await this.mutateMetadata(serializedMetadata);
this.settings = metadata.map(deserializeMetadata);
}
/**
* Typescript doesn't properly infer arguments, so they have to be rewritten explicitly
*/
async delete(args: DeleteSettingsValue | DeleteSettingsValue[] | string | string[]) {
if (!this.deleteMetadata) {
throw new Error(
"Delete not implemented. Ensure MetadataManager is configured with deleteMetadata param in constructor"
);
}
const argsArray = Array.isArray(args) ? args : [args];
const keysToDelete = argsArray.map((keyOrDomainPair) => {
if (typeof keyOrDomainPair === "string") {
return keyOrDomainPair;
}
const { key, domain } = keyOrDomainPair;
return constructDomainSpecificKey(key, domain);
});
await this.deleteMetadata(keysToDelete);
this.settings = this.settings.filter((setting) => !argsArray.includes(setting.key));
}
}

View file

@ -4,7 +4,18 @@ export type SettingsValue = {
domain?: string;
};
export type DeleteSettingsValue = {
key: string;
domain: string;
};
type DeleteFnSimple = (keys: string | string[]) => Promise<void>;
type DeleteFnWithDomain = (
keysWithDomain: DeleteSettingsValue | DeleteSettingsValue[]
) => Promise<void>;
export interface SettingsManager {
get: (key: string, domain?: string) => Promise<string | undefined>;
set: (settings: SettingsValue[] | SettingsValue) => Promise<void>;
delete: DeleteFnSimple | DeleteFnWithDomain;
}